From cfc75a05b3ae78e274be65fe134f955d4492216d Mon Sep 17 00:00:00 2001 From: thesn10 <38666407+thesn10@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:57:01 +0200 Subject: [PATCH] impl solisting --- Cargo.lock | 290 +++++++++++++- programs/solisting/Cargo.toml | 11 +- programs/solisting/src/instructions.rs | 20 +- .../src/instructions/accept_order.rs | 72 ++++ .../src/instructions/cancel_order.rs | 81 ++++ .../src/instructions/close_listing.rs | 22 ++ .../src/instructions/close_stale_order.rs | 42 ++ .../src/instructions/create_listing.rs | 47 +++ .../src/instructions/create_order.rs | 165 ++++++++ .../src/instructions/reject_order.rs | 88 +++++ .../src/instructions/update_listing.rs | 38 ++ programs/solisting/src/lib.rs | 68 +++- programs/solisting/src/oracle.rs | 36 +- programs/solisting/tests/common/mod.rs | 371 ++++++++++++++++++ programs/solisting/tests/test_listings.rs | 129 ++++++ programs/solisting/tests/test_orders.rs | 307 +++++++++++++++ 16 files changed, 1728 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 067eb33..45cecd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ "solana-svm-measure", "solana-svm-timings", "solana-svm-type-overrides", - "solana-sysvar", + "solana-sysvar 3.1.1", "solana-sysvar-id", "solana-transaction-context", "thiserror 2.0.18", @@ -224,7 +224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8140a40827bdfd74720f1f3084778fa081262f2f43bd4bdbc350f98ce1b341c6" dependencies = [ "anchor-syn", - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 1.0.109", @@ -259,7 +259,7 @@ dependencies = [ "anchor-lang-idl", "base64 0.21.7", "bincode", - "borsh", + "borsh 1.6.1", "bytemuck", "const-crypto", "solana-account-info", @@ -281,7 +281,7 @@ dependencies = [ "solana-sdk-ids", "solana-stake-interface", "solana-system-interface 2.0.0", - "solana-sysvar", + "solana-sysvar 3.1.1", "solana-sysvar-id", "thiserror 1.0.69", ] @@ -696,17 +696,40 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" +dependencies = [ + "borsh-derive 0.10.4", + "hashbrown 0.13.2", +] + [[package]] name = "borsh" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ - "borsh-derive", + "borsh-derive 1.6.1", "bytes", "cfg_aliases", ] +[[package]] +name = "borsh-derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" +dependencies = [ + "borsh-derive-internal", + "borsh-schema-derive-internal", + "proc-macro-crate 0.1.5", + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "borsh-derive" version = "1.6.1" @@ -714,12 +737,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "borsh-derive-internal" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "borsh-schema-derive-internal" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bs58" version = "0.5.1" @@ -784,7 +829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a98356df42a2eb1bd8f1793ae4ee4de48e384dd974ce5eac8eee802edb7492be" dependencies = [ "serde", - "toml", + "toml 0.8.23", ] [[package]] @@ -1245,6 +1290,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fast-math" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2465292146cdfc2011350fe3b1c616ac83cf0faeedb33463ba1c332ed8948d66" +dependencies = [ + "ieee754", +] + [[package]] name = "feature-probe" version = "0.1.1" @@ -1427,6 +1481,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1451,6 +1514,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "ieee754" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9007da9cacbd3e6343da136e98b0d2df013f553d35bdec8b518f07bea768e19c" + [[package]] name = "indexmap" version = "2.14.0" @@ -1691,7 +1760,7 @@ dependencies = [ "solana-svm-transaction", "solana-system-interface 3.2.0", "solana-system-program", - "solana-sysvar", + "solana-sysvar 3.1.1", "solana-sysvar-id", "solana-transaction", "solana-transaction-context", @@ -1720,6 +1789,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "merlin" version = "3.0.0" @@ -1937,6 +2015,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1955,6 +2042,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyth-solana-receiver-sdk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b7854c4176470c8d86de301dc5b57ac84227dabb9527328b585fc332962d60b" +dependencies = [ + "anchor-lang", + "hex", + "pythnet-sdk", + "solana-program", +] + +[[package]] +name = "pythnet-sdk" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498d20fd330277697aaee92f341bdabdb4695b10e05f054157a18ad8b7746a17" +dependencies = [ + "anchor-lang", + "bincode", + "borsh 0.10.4", + "bytemuck", + "byteorder", + "fast-math", + "hex", + "rustc_version", + "serde", + "sha3", + "slow_primes", + "solana-program", + "thiserror 1.0.69", +] + [[package]] name = "qstring" version = "0.7.2" @@ -2340,6 +2460,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slow_primes" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58267dd2fbaa6dceecba9e3e106d2d90a2b02497c0e8b01b8759beccf5113938" +dependencies = [ + "num", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2361,7 +2490,7 @@ dependencies = [ "solana-instruction-error", "solana-pubkey 4.2.0", "solana-sdk-ids", - "solana-sysvar", + "solana-sysvar 3.1.1", ] [[package]] @@ -2370,6 +2499,8 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95" dependencies = [ + "bincode", + "serde_core", "solana-address 2.6.0", "solana-program-error", "solana-program-memory", @@ -2390,7 +2521,7 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1384b52c435a750cc9c538760fc7bb472fd78e65a9900a2d07312c5bb335b72" dependencies = [ - "borsh", + "borsh 1.6.1", "bytemuck", "bytemuck_derive", "curve25519-dalek", @@ -2488,7 +2619,7 @@ version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c04abbae16f57178a163125805637b8a076175bb5c0002fb04f4792bea901cf7" dependencies = [ - "borsh", + "borsh 1.6.1", ] [[package]] @@ -2608,7 +2739,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9" dependencies = [ - "borsh", + "borsh 1.6.1", "solana-instruction", "solana-sdk-ids", ] @@ -2706,6 +2837,33 @@ dependencies = [ "solana-sysvar-id", ] +[[package]] +name = "solana-epoch-stake" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027e6d0b9e7daac5b2ac7c3f9ca1b727861121d9ef05084cf435ff736051e7c2" +dependencies = [ + "solana-define-syscall 5.1.0", + "solana-pubkey 4.2.0", +] + +[[package]] +name = "solana-example-mocks" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb265ff95e28eceda117e2e3d2d2a611ecbbfe911dfeeeecd1521814540ffab" +dependencies = [ + "serde", + "serde_derive", + "solana-hash 4.3.0", + "solana-instruction", + "solana-nonce", + "solana-pubkey 4.2.0", + "solana-sdk-ids", + "solana-system-interface 3.2.0", + "thiserror 2.0.18", +] + [[package]] name = "solana-feature-gate-interface" version = "3.1.0" @@ -2768,7 +2926,7 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1b113239362cee7093bfb250467138f079a2a03673181dc15bff6ccd677912d" dependencies = [ - "borsh", + "borsh 1.6.1", "bytemuck", "bytemuck_derive", "five8", @@ -2786,6 +2944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37ebb0ffd19263051bc3f683fcc086134b8ff23af894dcb63f7563c7137b42f1" dependencies = [ "bincode", + "borsh 1.6.1", "serde", "serde_derive", "solana-define-syscall 5.1.0", @@ -3022,6 +3181,53 @@ dependencies = [ "num-traits", ] +[[package]] +name = "solana-program" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778f08fb0eaf52c9a3bef2978247f7fab0ccfddc44cfddb936d5ad9f98ede886" +dependencies = [ + "memoffset", + "solana-account-info", + "solana-big-mod-exp", + "solana-blake3-hasher", + "solana-borsh", + "solana-clock", + "solana-cpi", + "solana-define-syscall 5.1.0", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-epoch-stake", + "solana-example-mocks", + "solana-fee-calculator", + "solana-hash 4.3.0", + "solana-instruction", + "solana-instruction-error", + "solana-instructions-sysvar", + "solana-keccak-hasher", + "solana-last-restart-slot", + "solana-msg", + "solana-native-token", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey 4.2.0", + "solana-rent 4.2.0", + "solana-sdk-ids", + "solana-secp256k1-recover", + "solana-serde-varint", + "solana-serialize-utils", + "solana-sha256-hasher", + "solana-short-vec", + "solana-slot-hashes", + "solana-slot-history", + "solana-stable-layout", + "solana-sysvar 4.0.0", + "solana-sysvar-id", +] + [[package]] name = "solana-program-entrypoint" version = "3.1.1" @@ -3040,7 +3246,9 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" dependencies = [ - "borsh", + "borsh 1.6.1", + "serde", + "serde_derive", ] [[package]] @@ -3106,7 +3314,7 @@ dependencies = [ "solana-svm-transaction", "solana-svm-type-overrides", "solana-system-interface 2.0.0", - "solana-sysvar", + "solana-sysvar 3.1.1", "solana-sysvar-id", "solana-transaction-context", "thiserror 2.0.18", @@ -3149,7 +3357,11 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9809b081e99bc142ce803bcd7ee18306759ce3b30a96a9da3f6f41c45e50ef0" dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", "solana-sdk-macro", + "solana-sysvar-id", ] [[package]] @@ -3344,7 +3556,7 @@ dependencies = [ "solana-program-error", "solana-pubkey 3.0.0", "solana-system-interface 2.0.0", - "solana-sysvar", + "solana-sysvar 3.1.1", "solana-sysvar-id", ] @@ -3467,7 +3679,7 @@ dependencies = [ "solana-svm-log-collector", "solana-svm-type-overrides", "solana-system-interface 2.0.0", - "solana-sysvar", + "solana-sysvar 3.1.1", "solana-transaction-context", ] @@ -3503,6 +3715,40 @@ dependencies = [ "solana-sysvar-id", ] +[[package]] +name = "solana-sysvar" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1632b69b4f72489db5949a10e8308c229dfa003f99ecaa7477b376807c7b81f4" +dependencies = [ + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "lazy_static", + "serde", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-define-syscall 5.1.0", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash 4.3.0", + "solana-instruction", + "solana-last-restart-slot", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-pubkey 4.2.0", + "solana-rent 4.2.0", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-slot-hashes", + "solana-slot-history", + "solana-sysvar-id", +] + [[package]] name = "solana-sysvar-id" version = "3.1.0" @@ -3735,6 +3981,7 @@ dependencies = [ "descro", "descro_ext_resolvers", "litesvm", + "pyth-solana-receiver-sdk", "solana-keypair", "solana-message", "solana-signer", @@ -3840,6 +4087,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/programs/solisting/Cargo.toml b/programs/solisting/Cargo.toml index d41fafb..dcf2d27 100644 --- a/programs/solisting/Cargo.toml +++ b/programs/solisting/Cargo.toml @@ -22,15 +22,14 @@ custom-panic = [] anchor-lang = "1.0.2" descro = { path = "../../../descro/programs/descro", features = ["cpi"] } descro_ext_resolvers = { path = "../../../descro/programs/descro_ext_resolvers" } -# pyth-solana-receiver-sdk is not used directly — oracle.rs reads PriceUpdateV2 account data -# manually (borsh layout matching the SDK) to avoid SDK compilation issues with this Rust toolchain. +pyth-solana-receiver-sdk = "1.2.0" [dev-dependencies] litesvm = "0.12.0" -solana-message = "3.0.1" -solana-transaction = "3.0.2" -solana-signer = "3.0.0" -solana-keypair = "3.0.1" +solana-message = "4.2.1" +solana-transaction = "4.1.3" +solana-signer = "3.0.1" +solana-keypair = "3.1.2" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/programs/solisting/src/instructions.rs b/programs/solisting/src/instructions.rs index b5a1d31..0d0d9fd 100644 --- a/programs/solisting/src/instructions.rs +++ b/programs/solisting/src/instructions.rs @@ -1,3 +1,21 @@ -pub mod initialize; +#![allow(ambiguous_glob_reexports)] +pub mod accept_order; +pub mod cancel_order; +pub mod close_listing; +pub mod close_stale_order; +pub mod create_listing; +pub mod create_order; +pub mod initialize; +pub mod reject_order; +pub mod update_listing; + +pub use accept_order::*; +pub use cancel_order::*; +pub use close_listing::*; +pub use close_stale_order::*; +pub use create_listing::*; +pub use create_order::*; pub use initialize::*; +pub use reject_order::*; +pub use update_listing::*; diff --git a/programs/solisting/src/instructions/accept_order.rs b/programs/solisting/src/instructions/accept_order.rs index e69de29..ec7eec9 100644 --- a/programs/solisting/src/instructions/accept_order.rs +++ b/programs/solisting/src/instructions/accept_order.rs @@ -0,0 +1,72 @@ +use anchor_lang::prelude::*; +use crate::state::{ListingAccount, OrderAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct AcceptOrder<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + /// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated (descro enforces this) + pub resolver: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized, + close = seller, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — state checked by descro.seller_confirm + #[account( + mut, + seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + constraint = escrow_account.key() == order_account.escrow_account @ SolistingError::EscrowStateUnexpected, + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Optional resolver registry entry — passed through to descro.seller_confirm + pub resolver_entry: UncheckedAccount<'info>, + + /// CHECK: Descro program + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + descro::cpi::seller_confirm( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::SellerConfirm { + seller: ctx.accounts.seller.to_account_info(), + resolver: ctx.accounts.resolver.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + resolver_entry: ctx.accounts.resolver_entry.to_account_info(), + }, + ), + )?; + + let listing = &mut ctx.accounts.listing_account; + listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1); + listing.quantity = listing.quantity.saturating_sub(1); + + Ok(()) +} diff --git a/programs/solisting/src/instructions/cancel_order.rs b/programs/solisting/src/instructions/cancel_order.rs index e69de29..f5cbe91 100644 --- a/programs/solisting/src/instructions/cancel_order.rs +++ b/programs/solisting/src/instructions/cancel_order.rs @@ -0,0 +1,81 @@ +use anchor_lang::prelude::*; +use anchor_lang::AccountDeserialize; +use crate::state::{ListingAccount, OrderAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct CancelOrder<'info> { + #[account(mut)] + pub buyer: Signer<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized, + close = buyer, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — read defensively before CPI + #[account( + mut, + seeds = [b"escrow", order_account.seller.as_ref(), &order_account.escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Descro vault + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump, + seeds::program = descro::id(), + )] + pub vault: UncheckedAccount<'info>, + + /// CHECK: Descro program + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + if !ctx.accounts.escrow_account.data_is_empty() { + let data = ctx.accounts.escrow_account.try_borrow_data()?; + if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) { + if escrow.state == descro::EscrowState::AwaitingSellerConfirm { + drop(data); + descro::cpi::cancel( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::Cancel { + canceller: ctx.accounts.buyer.to_account_info(), + buyer: ctx.accounts.buyer.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + vault: ctx.accounts.vault.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + }, + ), + )?; + } + } + } + + ctx.accounts.listing_account.quantity_reserved = + ctx.accounts.listing_account.quantity_reserved.saturating_sub(1); + Ok(()) +} diff --git a/programs/solisting/src/instructions/close_listing.rs b/programs/solisting/src/instructions/close_listing.rs index e69de29..84d960d 100644 --- a/programs/solisting/src/instructions/close_listing.rs +++ b/programs/solisting/src/instructions/close_listing.rs @@ -0,0 +1,22 @@ +use anchor_lang::prelude::*; +use crate::state::ListingAccount; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct CloseListing<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + close = seller, + )] + pub listing_account: Account<'info, ListingAccount>, +} + +pub fn handler(_ctx: Context) -> Result<()> { + Ok(()) +} diff --git a/programs/solisting/src/instructions/close_stale_order.rs b/programs/solisting/src/instructions/close_stale_order.rs index e69de29..bd1b709 100644 --- a/programs/solisting/src/instructions/close_stale_order.rs +++ b/programs/solisting/src/instructions/close_stale_order.rs @@ -0,0 +1,42 @@ +use anchor_lang::prelude::*; +use anchor_lang::AccountDeserialize; +use crate::state::OrderAccount; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct CloseStaleOrder<'info> { + #[account(mut)] + pub caller: Signer<'info>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + close = caller, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — must be in terminal state or closed + pub escrow_account: UncheckedAccount<'info>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.escrow_account.key() == ctx.accounts.order_account.escrow_account, + SolistingError::EscrowStateUnexpected + ); + + // Closed (zero data) escrow = already resolved = ok to clean up + if !ctx.accounts.escrow_account.data_is_empty() { + let data = ctx.accounts.escrow_account.try_borrow_data()?; + if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) { + require!( + escrow.state == descro::EscrowState::Cancelled + || escrow.state == descro::EscrowState::Complete, + SolistingError::EscrowStateUnexpected + ); + } + } + + Ok(()) +} diff --git a/programs/solisting/src/instructions/create_listing.rs b/programs/solisting/src/instructions/create_listing.rs index e69de29..2952ca4 100644 --- a/programs/solisting/src/instructions/create_listing.rs +++ b/programs/solisting/src/instructions/create_listing.rs @@ -0,0 +1,47 @@ +use anchor_lang::prelude::*; +use crate::state::{AltCurrencyConfig, Currency, ListingAccount}; + +#[derive(Accounts)] +#[instruction(listing_id: u64)] +pub struct CreateListing<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + #[account( + init, + payer = seller, + space = 8 + ListingAccount::INIT_SPACE, + seeds = [b"listing", seller.key().as_ref(), &listing_id.to_le_bytes()], + bump + )] + pub listing_account: Account<'info, ListingAccount>, + + pub system_program: Program<'info, System>, +} + +pub fn handler( + ctx: Context, + listing_id: u64, + canonical_currency: Currency, + price: u64, + canonical_oracle: Option, + alt_currencies: Vec, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, +) -> Result<()> { + let listing = &mut ctx.accounts.listing_account; + listing.seller = ctx.accounts.seller.key(); + listing.canonical_currency = canonical_currency; + listing.price = price; + listing.canonical_oracle = canonical_oracle; + listing.alt_currencies = alt_currencies; + listing.accepted_resolvers = accepted_resolvers; + listing.quantity = quantity; + listing.quantity_reserved = 0; + listing.metadata_uri = metadata_uri; + listing.listing_id = listing_id; + listing.is_active = true; + listing.bump = ctx.bumps.listing_account; + Ok(()) +} diff --git a/programs/solisting/src/instructions/create_order.rs b/programs/solisting/src/instructions/create_order.rs index e69de29..5246d8a 100644 --- a/programs/solisting/src/instructions/create_order.rs +++ b/programs/solisting/src/instructions/create_order.rs @@ -0,0 +1,165 @@ +use anchor_lang::prelude::*; +use crate::state::{Currency, ListingAccount, OrderAccount}; +use crate::error::SolistingError; +use crate::oracle; + +#[derive(Accounts)] +#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: Currency)] +pub struct CreateOrder<'info> { + #[account(mut)] + pub buyer: Signer<'info>, + + /// CHECK: Seller — verified against listing.seller via constraint + pub seller: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = listing_account.is_active @ SolistingError::ListingNotActive, + constraint = listing_account.quantity > listing_account.quantity_reserved @ SolistingError::OutOfStock, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + init, + payer = buyer, + space = 8 + OrderAccount::INIT_SPACE, + seeds = [b"order", listing_account.key().as_ref(), buyer.key().as_ref(), &order_id.to_le_bytes()], + bump + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount PDA — created by the CPI + #[account( + mut, + seeds = [b"escrow", seller.key().as_ref(), &escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Descro vault PDA — funded by the CPI + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump, + seeds::program = descro::id(), + )] + pub descro_vault: UncheckedAccount<'info>, + + /// CHECK: Pyth USD feed for the canonical currency (listing.canonical_oracle). + /// Pass SystemProgram as placeholder when canonical_oracle is None (stablecoin). + pub canonical_oracle: UncheckedAccount<'info>, + + /// CHECK: Pyth USD feed for the chosen alt currency (alt_cfg.usd_oracle). + /// Pass SystemProgram as placeholder when usd_oracle is None or paying canonical. + pub target_oracle: UncheckedAccount<'info>, + + /// CHECK: Descro program — verified in handler + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler( + ctx: Context, + order_id: u64, + escrow_id: u64, + resolver: Pubkey, + payment_currency: Currency, + expected_amount: u64, + max_slippage_bps: u16, +) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + let listing = &ctx.accounts.listing_account; + + if !listing.accepted_resolvers.is_empty() { + require!( + listing.accepted_resolvers.contains(&resolver), + SolistingError::ResolverNotAccepted + ); + } + + let amount = if payment_currency == listing.canonical_currency { + listing.price + } else { + let alt_cfg = listing + .alt_currencies + .iter() + .find(|c| c.currency == payment_currency) + .ok_or(error!(SolistingError::CurrencyNotAccepted))?; + + if let Some(expected_canonical) = listing.canonical_oracle { + require!( + ctx.accounts.canonical_oracle.key() == expected_canonical, + SolistingError::OracleMismatch + ); + } + if let Some(expected_target) = alt_cfg.usd_oracle { + require!( + ctx.accounts.target_oracle.key() == expected_target, + SolistingError::OracleMismatch + ); + } + + let c_oracle = listing + .canonical_oracle + .map(|_| ctx.accounts.canonical_oracle.as_ref()); + let t_oracle = alt_cfg + .usd_oracle + .map(|_| ctx.accounts.target_oracle.as_ref()); + + oracle::convert_with_slippage( + c_oracle, + &listing.canonical_currency, + listing.price, + t_oracle, + &payment_currency, + expected_amount, + max_slippage_bps, + )? + }; + + require!( + payment_currency == Currency::Sol, + SolistingError::SplNotImplemented + ); + + descro::cpi::buyer_create_escrow( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::BuyerCreateEscrow { + buyer: ctx.accounts.buyer.to_account_info(), + seller: ctx.accounts.seller.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + vault: ctx.accounts.descro_vault.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + }, + ), + amount, + Some(resolver), + escrow_id, + )?; + + ctx.accounts.listing_account.quantity_reserved += 1; + + let order = &mut ctx.accounts.order_account; + order.listing = ctx.accounts.listing_account.key(); + order.buyer = ctx.accounts.buyer.key(); + order.seller = ctx.accounts.seller.key(); + order.resolver = resolver; + order.payment_currency = payment_currency; + order.amount = amount; + order.escrow_account = ctx.accounts.escrow_account.key(); + order.escrow_id = escrow_id; + order.order_id = order_id; + order.created_at = Clock::get()?.unix_timestamp; + order.bump = ctx.bumps.order_account; + Ok(()) +} diff --git a/programs/solisting/src/instructions/reject_order.rs b/programs/solisting/src/instructions/reject_order.rs index e69de29..c7ebbf4 100644 --- a/programs/solisting/src/instructions/reject_order.rs +++ b/programs/solisting/src/instructions/reject_order.rs @@ -0,0 +1,88 @@ +use anchor_lang::prelude::*; +use anchor_lang::AccountDeserialize; +use crate::state::{ListingAccount, OrderAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct RejectOrder<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + /// CHECK: Buyer receives vault refund from descro.cancel + #[account(mut)] + pub buyer: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized, + constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized, + close = seller, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — read to check current state before CPI + #[account( + mut, + seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Descro vault — drained to buyer by descro.cancel + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump, + seeds::program = descro::id(), + )] + pub vault: UncheckedAccount<'info>, + + /// CHECK: Descro program + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + // Defensive: skip CPI if escrow already resolved (buyer cancelled directly on descro) + if !ctx.accounts.escrow_account.data_is_empty() { + let data = ctx.accounts.escrow_account.try_borrow_data()?; + if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) { + if escrow.state == descro::EscrowState::AwaitingSellerConfirm { + drop(data); + descro::cpi::cancel( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::Cancel { + canceller: ctx.accounts.seller.to_account_info(), + buyer: ctx.accounts.buyer.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + vault: ctx.accounts.vault.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + }, + ), + )?; + } + } + } + + ctx.accounts.listing_account.quantity_reserved = + ctx.accounts.listing_account.quantity_reserved.saturating_sub(1); + Ok(()) +} diff --git a/programs/solisting/src/instructions/update_listing.rs b/programs/solisting/src/instructions/update_listing.rs index e69de29..544c873 100644 --- a/programs/solisting/src/instructions/update_listing.rs +++ b/programs/solisting/src/instructions/update_listing.rs @@ -0,0 +1,38 @@ +use anchor_lang::prelude::*; +use crate::state::{AltCurrencyConfig, Currency, ListingAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct UpdateListing<'info> { + pub seller: Signer<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + constraint = listing_account.is_active @ SolistingError::ListingNotActive, + )] + pub listing_account: Account<'info, ListingAccount>, +} + +pub fn handler( + ctx: Context, + canonical_currency: Currency, + price: u64, + canonical_oracle: Option, + alt_currencies: Vec, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, +) -> Result<()> { + let listing = &mut ctx.accounts.listing_account; + listing.canonical_currency = canonical_currency; + listing.price = price; + listing.canonical_oracle = canonical_oracle; + listing.alt_currencies = alt_currencies; + listing.accepted_resolvers = accepted_resolvers; + listing.quantity = quantity; + listing.metadata_uri = metadata_uri; + Ok(()) +} diff --git a/programs/solisting/src/lib.rs b/programs/solisting/src/lib.rs index 6653a02..5929bf7 100644 --- a/programs/solisting/src/lib.rs +++ b/programs/solisting/src/lib.rs @@ -5,8 +5,7 @@ pub mod oracle; pub mod state; use anchor_lang::prelude::*; - -pub use constants::*; +pub use error::*; pub use instructions::*; pub use state::*; @@ -19,4 +18,69 @@ pub mod solisting { pub fn initialize(ctx: Context) -> Result<()> { initialize::handler(ctx) } + + pub fn create_listing( + ctx: Context, + listing_id: u64, + canonical_currency: state::Currency, + price: u64, + canonical_oracle: Option, + alt_currencies: Vec, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, + ) -> Result<()> { + create_listing::handler( + ctx, listing_id, canonical_currency, price, + canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri, + ) + } + + pub fn update_listing( + ctx: Context, + canonical_currency: state::Currency, + price: u64, + canonical_oracle: Option, + alt_currencies: Vec, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, + ) -> Result<()> { + update_listing::handler( + ctx, canonical_currency, price, + canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri, + ) + } + + pub fn close_listing(ctx: Context) -> Result<()> { + close_listing::handler(ctx) + } + + pub fn create_order( + ctx: Context, + order_id: u64, + escrow_id: u64, + resolver: Pubkey, + payment_currency: state::Currency, + expected_amount: u64, + max_slippage_bps: u16, + ) -> Result<()> { + create_order::handler(ctx, order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps) + } + + pub fn accept_order(ctx: Context) -> Result<()> { + accept_order::handler(ctx) + } + + pub fn reject_order(ctx: Context) -> Result<()> { + reject_order::handler(ctx) + } + + pub fn cancel_order(ctx: Context) -> Result<()> { + cancel_order::handler(ctx) + } + + pub fn close_stale_order(ctx: Context) -> Result<()> { + close_stale_order::handler(ctx) + } } diff --git a/programs/solisting/src/oracle.rs b/programs/solisting/src/oracle.rs index fc650c4..048243b 100644 --- a/programs/solisting/src/oracle.rs +++ b/programs/solisting/src/oracle.rs @@ -1,47 +1,17 @@ use anchor_lang::prelude::*; -use anchor_lang::AnchorDeserialize; +use anchor_lang::AccountDeserialize; +use pyth_solana_receiver_sdk::price_update::PriceUpdateV2; use crate::error::SolistingError; use crate::state::Currency; const ORACLE_MAX_AGE_SECS: i64 = 60; -/// Mirrors pyth_solana_receiver_sdk::price_update::VerificationLevel (borsh layout). -#[derive(AnchorSerialize, AnchorDeserialize, Clone)] -enum VerificationLevel { - Partial { num_signatures: u8 }, - Full, -} - -/// Mirrors pythnet_sdk::messages::PriceFeedMessage (borsh layout). -#[derive(AnchorSerialize, AnchorDeserialize, Clone)] -struct PriceFeedMessage { - pub feed_id: [u8; 32], - pub price: i64, - pub conf: u64, - pub exponent: i32, - pub publish_time: i64, - pub prev_publish_time: i64, - pub ema_price: i64, - pub ema_conf: u64, -} - -/// Mirrors pyth_solana_receiver_sdk::price_update::PriceUpdateV2 (borsh layout after discriminator). -#[derive(AnchorSerialize, AnchorDeserialize, Clone)] -struct PriceUpdateV2Local { - pub write_authority: Pubkey, - pub verification_level: VerificationLevel, - pub price_message: PriceFeedMessage, - pub posted_slot: u64, -} - /// Reads a Pyth V2 PriceFeedAccount and returns (price_i64, exponent_i32). /// `price * 10^exponent` is the USD value of 1 whole token. /// The caller must verify the account key matches listing state before calling this. fn read_usd_price(oracle_account: &AccountInfo) -> Result<(i64, i32)> { let data = oracle_account.try_borrow_data()?; - require!(data.len() > 8, SolistingError::OraclePriceUnavailable); - // Skip the 8-byte Anchor discriminator, then borsh-decode the rest. - let price_update = PriceUpdateV2Local::try_from_slice(&data[8..]) + let price_update = PriceUpdateV2::try_deserialize(&mut data.as_ref()) .map_err(|_| error!(SolistingError::OraclePriceUnavailable))?; let msg = &price_update.price_message; let now = Clock::get()?.unix_timestamp; diff --git a/programs/solisting/tests/common/mod.rs b/programs/solisting/tests/common/mod.rs index e69de29..6e134a3 100644 --- a/programs/solisting/tests/common/mod.rs +++ b/programs/solisting/tests/common/mod.rs @@ -0,0 +1,371 @@ +#![allow(dead_code, unused_imports)] + +use anchor_lang::{ + solana_program::{instruction::Instruction, system_program}, + AccountDeserialize, InstructionData, ToAccountMetas, +}; +use litesvm::LiteSVM; +use solana_keypair::Keypair; +use solana_message::{Message, VersionedMessage}; +use solana_signer::Signer; +use solana_transaction::versioned::VersionedTransaction; + +pub use anchor_lang::prelude::Pubkey; +pub use solisting::state::{AltCurrencyConfig, Currency, ListingAccount, OrderAccount}; + +pub fn setup() -> (LiteSVM, Keypair, Keypair) { + let mut svm = LiteSVM::new(); + + let solisting_bytes = include_bytes!("../../../../target/deploy/solisting.so"); + svm.add_program(solisting::id(), solisting_bytes).unwrap(); + + let descro_bytes = include_bytes!("../../../../../descro/target/deploy/descro.so"); + svm.add_program(descro::id(), descro_bytes).unwrap(); + + let seller = Keypair::new(); + let buyer = Keypair::new(); + svm.airdrop(&seller.pubkey(), 10_000_000_000).unwrap(); + svm.airdrop(&buyer.pubkey(), 10_000_000_000).unwrap(); + + (svm, seller, buyer) +} + +pub fn listing_pda(seller: &Pubkey, listing_id: u64) -> Pubkey { + Pubkey::find_program_address( + &[b"listing", seller.as_ref(), &listing_id.to_le_bytes()], + &solisting::id(), + ) + .0 +} + +pub fn order_pda(listing: Pubkey, buyer: Pubkey, order_id: u64) -> Pubkey { + Pubkey::find_program_address( + &[b"order", listing.as_ref(), buyer.as_ref(), &order_id.to_le_bytes()], + &solisting::id(), + ) + .0 +} + +pub fn escrow_pda(seller: &Pubkey, escrow_id: u64) -> Pubkey { + Pubkey::find_program_address( + &[b"escrow", seller.as_ref(), &escrow_id.to_le_bytes()], + &descro::id(), + ) + .0 +} + +pub fn vault_pda(escrow: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"vault", escrow.as_ref()], &descro::id()).0 +} + +pub fn read_listing(svm: &LiteSVM, seller: &Pubkey, listing_id: u64) -> ListingAccount { + let pda = listing_pda(seller, listing_id); + let account = svm.get_account(&pda).expect("listing account not found"); + ListingAccount::try_deserialize(&mut account.data.as_slice()).unwrap() +} + +pub fn read_order(svm: &LiteSVM, listing: Pubkey, buyer: Pubkey, order_id: u64) -> OrderAccount { + let pda = order_pda(listing, buyer, order_id); + let account = svm.get_account(&pda).expect("order account not found"); + OrderAccount::try_deserialize(&mut account.data.as_slice()).unwrap() +} + +pub fn send(svm: &mut LiteSVM, ixs: &[Instruction], signers: &[&Keypair]) { + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(ixs, Some(&signers[0].pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers).unwrap(); + svm.send_transaction(tx).expect("transaction failed"); +} + +pub fn try_send( + svm: &mut LiteSVM, + ixs: &[Instruction], + signers: &[&Keypair], +) -> Result<(), Box> { + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(ixs, Some(&signers[0].pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers) + .map_err(|e| Box::new(e) as Box)?; + svm.send_transaction(tx) + .map_err(|e| Box::new(e) as Box) +} + +// --- Listing instruction builders --- + +pub fn ix_create_listing( + seller: &Pubkey, + listing_id: u64, + canonical_currency: Currency, + price: u64, + canonical_oracle: Option, + alt_currencies: Vec, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, +) -> Instruction { + let listing_account = listing_pda(seller, listing_id); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::CreateListing { + listing_id, + canonical_currency, + price, + canonical_oracle, + alt_currencies, + accepted_resolvers, + quantity, + metadata_uri, + } + .data(), + solisting::accounts::CreateListing { + seller: *seller, + listing_account, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +pub fn ix_update_listing( + seller: &Pubkey, + listing_id: u64, + canonical_currency: Currency, + price: u64, + canonical_oracle: Option, + alt_currencies: Vec, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, +) -> Instruction { + let listing_account = listing_pda(seller, listing_id); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::UpdateListing { + canonical_currency, + price, + canonical_oracle, + alt_currencies, + accepted_resolvers, + quantity, + metadata_uri, + } + .data(), + solisting::accounts::UpdateListing { + seller: *seller, + listing_account, + } + .to_account_metas(None), + ) +} + +pub fn ix_close_listing(seller: &Pubkey, listing_id: u64) -> Instruction { + let listing_account = listing_pda(seller, listing_id); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::CloseListing {}.data(), + solisting::accounts::CloseListing { + seller: *seller, + listing_account, + } + .to_account_metas(None), + ) +} + +// --- Order instruction builders --- + +/// Build a create_order instruction paying in the canonical currency (no oracle accounts needed). +/// For alt-currency orders, use ix_create_order_with_resolver with explicit oracle keys. +pub fn ix_create_order( + buyer: &Pubkey, + seller: &Pubkey, + listing_id: u64, + order_id: u64, + payment_currency: Currency, + max_slippage_bps: u16, + canonical_oracle: Option, + target_oracle: Option, +) -> Instruction { + let resolver = Pubkey::new_unique(); + ix_create_order_with_resolver( + buyer, + seller, + listing_id, + order_id, + payment_currency, + max_slippage_bps, + canonical_oracle, + target_oracle, + resolver, + ) +} + +pub fn ix_create_order_with_resolver( + buyer: &Pubkey, + seller: &Pubkey, + listing_id: u64, + order_id: u64, + payment_currency: Currency, + max_slippage_bps: u16, + canonical_oracle: Option, + target_oracle: Option, + resolver: Pubkey, +) -> Instruction { + let listing = listing_pda(seller, listing_id); + let order = order_pda(listing, *buyer, order_id); + // escrow_id is derived from the first 8 bytes of the order PDA to guarantee uniqueness + let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap()); + let escrow_account = escrow_pda(seller, escrow_id); + let descro_vault = vault_pda(&escrow_account); + let canonical_oracle_key = canonical_oracle.unwrap_or(system_program::ID); + let target_oracle_key = target_oracle.unwrap_or(system_program::ID); + // expected_amount = 0 disables slippage check (tests compute it externally or don't need it) + let expected_amount: u64 = 0; + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::CreateOrder { + order_id, + escrow_id, + resolver, + payment_currency, + expected_amount, + max_slippage_bps, + } + .data(), + solisting::accounts::CreateOrder { + buyer: *buyer, + seller: *seller, + listing_account: listing, + order_account: order, + escrow_account, + descro_vault, + canonical_oracle: canonical_oracle_key, + target_oracle: target_oracle_key, + descro_program: descro::id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +pub fn ix_accept_order( + seller: &Pubkey, + listing_id: u64, + buyer: &Pubkey, + order_id: u64, + escrow_id: u64, + resolver: Pubkey, +) -> Instruction { + let listing_account = listing_pda(seller, listing_id); + let order_account = order_pda(listing_account, *buyer, order_id); + let escrow_account = escrow_pda(seller, escrow_id); + // Pass a fresh key as resolver_entry — it won't exist, so descro skips the policy check + let resolver_entry = Pubkey::new_unique(); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::AcceptOrder {}.data(), + solisting::accounts::AcceptOrder { + seller: *seller, + resolver, + listing_account, + order_account, + escrow_account, + resolver_entry, + descro_program: descro::id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +pub fn ix_reject_order( + seller: &Pubkey, + listing_id: u64, + buyer: &Pubkey, + order_id: u64, + escrow_id: u64, +) -> Instruction { + let listing_account = listing_pda(seller, listing_id); + let order_account = order_pda(listing_account, *buyer, order_id); + let escrow_account = escrow_pda(seller, escrow_id); + let vault = vault_pda(&escrow_account); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::RejectOrder {}.data(), + solisting::accounts::RejectOrder { + seller: *seller, + buyer: *buyer, + listing_account, + order_account, + escrow_account, + vault, + descro_program: descro::id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +pub fn ix_cancel_order( + buyer: &Pubkey, + listing_id: u64, + seller: &Pubkey, + order_id: u64, + escrow_id: u64, +) -> Instruction { + let listing_account = listing_pda(seller, listing_id); + let order_account = order_pda(listing_account, *buyer, order_id); + let escrow_account = escrow_pda(seller, escrow_id); + let vault = vault_pda(&escrow_account); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::CancelOrder {}.data(), + solisting::accounts::CancelOrder { + buyer: *buyer, + listing_account, + order_account, + escrow_account, + vault, + descro_program: descro::id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +pub fn ix_close_stale_order( + caller: &Pubkey, + listing: Pubkey, + buyer: Pubkey, + order_id: u64, + escrow_account: Pubkey, +) -> Instruction { + let order_account = order_pda(listing, buyer, order_id); + Instruction::new_with_bytes( + solisting::id(), + &solisting::instruction::CloseStaleOrder {}.data(), + solisting::accounts::CloseStaleOrder { + caller: *caller, + order_account, + escrow_account, + } + .to_account_metas(None), + ) +} + +/// Build a descro cancel instruction directly (bypasses solisting — used to test defensive paths) +pub fn ix_descro_cancel(buyer: &Pubkey, seller: &Pubkey, escrow_id: u64) -> Instruction { + let escrow_account = escrow_pda(seller, escrow_id); + let vault = vault_pda(&escrow_account); + Instruction::new_with_bytes( + descro::id(), + &descro::instruction::Cancel {}.data(), + descro::accounts::Cancel { + canceller: *buyer, + buyer: *buyer, + escrow_account, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} diff --git a/programs/solisting/tests/test_listings.rs b/programs/solisting/tests/test_listings.rs index e69de29..df6681e 100644 --- a/programs/solisting/tests/test_listings.rs +++ b/programs/solisting/tests/test_listings.rs @@ -0,0 +1,129 @@ +mod common; +use common::*; + +#[test] +fn seller_can_create_sol_only_listing() { + let (mut svm, seller, _) = setup(); + let listing_id: u64 = 1; + let price = 100_000_000u64; // 0.1 SOL in lamports + + let ix = ix_create_listing( + &seller.pubkey(), + listing_id, + Currency::Sol, + price, + None, + vec![], + vec![], + 10, + "ipfs://test".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let listing = read_listing(&svm, &seller.pubkey(), listing_id); + assert_eq!(listing.canonical_currency, Currency::Sol); + assert_eq!(listing.price, price); + assert_eq!(listing.alt_currencies.len(), 0); + assert_eq!(listing.canonical_oracle, None); + assert_eq!(listing.quantity, 10); + assert_eq!(listing.quantity_reserved, 0); + assert!(listing.is_active); +} + +#[test] +fn seller_can_create_listing_with_usdc_alt_stablecoin() { + let (mut svm, seller, _) = setup(); + let usdc_mint = Pubkey::new_unique(); + let sol_usd_feed = Pubkey::new_unique(); + let ix = ix_create_listing( + &seller.pubkey(), + 10, + Currency::Sol, + 1_000_000_000, + Some(sol_usd_feed), + vec![AltCurrencyConfig { + currency: Currency::Spl { mint: usdc_mint, decimals: 6 }, + usd_oracle: None, + }], + vec![], + 5, + "ipfs://x".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let listing = read_listing(&svm, &seller.pubkey(), 10); + assert_eq!(listing.canonical_oracle, Some(sol_usd_feed)); + assert_eq!(listing.alt_currencies[0].usd_oracle, None); +} + +#[test] +fn seller_can_create_listing_with_bonk_alt_oracle() { + let (mut svm, seller, _) = setup(); + let bonk_mint = Pubkey::new_unique(); + let sol_usd_feed = Pubkey::new_unique(); + let bonk_usd_feed = Pubkey::new_unique(); + let ix = ix_create_listing( + &seller.pubkey(), + 11, + Currency::Sol, + 1_000_000_000, + Some(sol_usd_feed), + vec![AltCurrencyConfig { + currency: Currency::Spl { mint: bonk_mint, decimals: 5 }, + usd_oracle: Some(bonk_usd_feed), + }], + vec![], + 5, + "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let listing = read_listing(&svm, &seller.pubkey(), 11); + assert_eq!(listing.canonical_oracle, Some(sol_usd_feed)); + assert_eq!(listing.alt_currencies[0].usd_oracle, Some(bonk_usd_feed)); +} + +#[test] +fn seller_can_update_listing() { + let (mut svm, seller, _) = setup(); + let listing_id: u64 = 2; + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, + None, vec![], vec![], 10, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let ix_update = ix_update_listing( + &seller.pubkey(), + listing_id, + Currency::Sol, + 2_000_000_000, + None, + vec![], + vec![], + 20, + "ipfs://new".to_string(), + ); + send(&mut svm, &[ix_update], &[&seller]); + + let listing = read_listing(&svm, &seller.pubkey(), listing_id); + assert_eq!(listing.price, 2_000_000_000); + assert_eq!(listing.quantity, 20); +} + +#[test] +fn seller_can_close_listing() { + let (mut svm, seller, _) = setup(); + let listing_id: u64 = 3; + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, + None, vec![], vec![], 5, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let ix_close = ix_close_listing(&seller.pubkey(), listing_id); + send(&mut svm, &[ix_close], &[&seller]); + + let pda = listing_pda(&seller.pubkey(), listing_id); + assert!(svm.get_account(&pda).is_none()); +} diff --git a/programs/solisting/tests/test_orders.rs b/programs/solisting/tests/test_orders.rs index e69de29..b6f7bf0 100644 --- a/programs/solisting/tests/test_orders.rs +++ b/programs/solisting/tests/test_orders.rs @@ -0,0 +1,307 @@ +mod common; +use common::*; +use solana_keypair::Keypair; +use solana_signer::Signer; + +#[test] +fn buyer_can_create_order_canonical_sol() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 1u64; + let price = 100_000_000u64; + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, price, + None, vec![], vec![], 5, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let order_id = 1u64; + let ix_order = ix_create_order( + &buyer.pubkey(), + &seller.pubkey(), + listing_id, + order_id, + Currency::Sol, + 0, + None, + None, + ); + send(&mut svm, &[ix_order], &[&buyer]); + + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), order_id); + assert_eq!(order.payment_currency, Currency::Sol); + assert_eq!(order.amount, price); + + let listing = read_listing(&svm, &seller.pubkey(), listing_id); + assert_eq!(listing.quantity_reserved, 1); +} + +#[test] +fn create_order_fails_if_listing_inactive() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 2u64; + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, + None, vec![], vec![], 1, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + let ix_close = ix_close_listing(&seller.pubkey(), listing_id); + send(&mut svm, &[ix_close], &[&seller]); + + let result = try_send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + assert!(result.is_err()); +} + +#[test] +fn create_order_fails_if_out_of_stock() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 3u64; + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, 100_000_000, + None, vec![], vec![], 1, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let buyer2 = Keypair::new(); + svm.airdrop(&buyer2.pubkey(), 10_000_000_000).unwrap(); + let result = try_send( + &mut svm, + &[ix_create_order(&buyer2.pubkey(), &seller.pubkey(), listing_id, 2, Currency::Sol, 0, None, None)], + &[&buyer2], + ); + assert!(result.is_err()); +} + +#[test] +fn create_order_fails_if_currency_not_accepted() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 4u64; + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, 100_000_000, + None, vec![], vec![], 5, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let fake_mint = Pubkey::new_unique(); + let result = try_send( + &mut svm, + &[ix_create_order( + &buyer.pubkey(), &seller.pubkey(), listing_id, 1, + Currency::Spl { mint: fake_mint, decimals: 6 }, 0, None, None, + )], + &[&buyer], + ); + assert!(result.is_err()); +} + +#[test] +fn create_order_fails_if_resolver_not_accepted() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 5u64; + let allowed_resolver = Pubkey::new_unique(); + let ix = ix_create_listing( + &seller.pubkey(), listing_id, Currency::Sol, 100_000_000, + None, vec![], vec![allowed_resolver], 5, "".to_string(), + ); + send(&mut svm, &[ix], &[&seller]); + + let result = try_send( + &mut svm, + &[ix_create_order_with_resolver( + &buyer.pubkey(), &seller.pubkey(), listing_id, 1, + Currency::Sol, 0, None, None, Pubkey::new_unique(), + )], + &[&buyer], + ); + assert!(result.is_err()); +} + +#[test] +fn seller_accept_creates_active_descro_escrow() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 10u64; + let price = 100_000_000u64; + send( + &mut svm, + &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())], + &[&seller], + ); + let order_id = 1u64; + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, order_id, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), order_id); + + send( + &mut svm, + &[ix_accept_order(&seller.pubkey(), listing_id, &buyer.pubkey(), order_id, order.escrow_id, order.resolver)], + &[&seller], + ); + + let order_key = order_pda(listing_key, buyer.pubkey(), order_id); + assert!(svm.get_account(&order_key).is_none()); + + let listing = read_listing(&svm, &seller.pubkey(), listing_id); + assert_eq!(listing.quantity_reserved, 0); + assert_eq!(listing.quantity, 4); +} + +#[test] +fn seller_can_reject_order() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 20u64; + let price = 100_000_000u64; + send( + &mut svm, + &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())], + &[&seller], + ); + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0); + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), 1); + + send( + &mut svm, + &[ix_reject_order(&seller.pubkey(), listing_id, &buyer.pubkey(), 1, order.escrow_id)], + &[&seller], + ); + + let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(buyer_after >= buyer_before + price - 10_000); + assert_eq!(read_listing(&svm, &seller.pubkey(), listing_id).quantity_reserved, 0); + assert!(svm.get_account(&order_pda(listing_key, buyer.pubkey(), 1)).is_none()); +} + +#[test] +fn buyer_can_cancel_order() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 21u64; + send( + &mut svm, + &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], + &[&seller], + ); + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), 1); + send( + &mut svm, + &[ix_cancel_order(&buyer.pubkey(), listing_id, &seller.pubkey(), 1, order.escrow_id)], + &[&buyer], + ); + + assert_eq!(read_listing(&svm, &seller.pubkey(), listing_id).quantity_reserved, 0); +} + +#[test] +fn reject_handles_already_cancelled_escrow() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 22u64; + send( + &mut svm, + &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], + &[&seller], + ); + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), 1); + // Buyer cancels directly on descro (bypassing solisting) + send(&mut svm, &[ix_descro_cancel(&buyer.pubkey(), &seller.pubkey(), order.escrow_id)], &[&buyer]); + // Seller calls solisting reject — should succeed without CPI error + send( + &mut svm, + &[ix_reject_order(&seller.pubkey(), listing_id, &buyer.pubkey(), 1, order.escrow_id)], + &[&seller], + ); +} + +#[test] +fn anyone_can_close_stale_order_after_terminal_escrow() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 30u64; + send( + &mut svm, + &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], + &[&seller], + ); + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), 1); + // Buyer cancels on descro directly → escrow is Cancelled + send(&mut svm, &[ix_descro_cancel(&buyer.pubkey(), &seller.pubkey(), order.escrow_id)], &[&buyer]); + + let janitor = Keypair::new(); + svm.airdrop(&janitor.pubkey(), 1_000_000).unwrap(); + let janitor_before = svm.get_account(&janitor.pubkey()).unwrap().lamports; + send( + &mut svm, + &[ix_close_stale_order(&janitor.pubkey(), listing_key, buyer.pubkey(), 1, order.escrow_account)], + &[&janitor], + ); + + assert!(svm.get_account(&order_pda(listing_key, buyer.pubkey(), 1)).is_none()); + let janitor_after = svm.get_account(&janitor.pubkey()).unwrap().lamports; + assert!(janitor_after > janitor_before); +} + +#[test] +fn close_stale_order_fails_if_escrow_still_active() { + let (mut svm, seller, buyer) = setup(); + let listing_id = 31u64; + send( + &mut svm, + &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], + &[&seller], + ); + send( + &mut svm, + &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], + &[&buyer], + ); + + let listing_key = listing_pda(&seller.pubkey(), listing_id); + let order = read_order(&svm, listing_key, buyer.pubkey(), 1); + + let janitor = Keypair::new(); + svm.airdrop(&janitor.pubkey(), 1_000_000).unwrap(); + let result = try_send( + &mut svm, + &[ix_close_stale_order(&janitor.pubkey(), listing_key, buyer.pubkey(), 1, order.escrow_account)], + &[&janitor], + ); + assert!(result.is_err()); +}