diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 000000000..32212aab1 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,10 @@ +# cargo-audit configuration +# https://docs.rs/cargo-audit/latest/cargo_audit/config/ + +[advisories] +# Ignore the lru unsound advisory - it comes from near-vm-runner which is +# locked to lru ^0.12.3 and cannot be updated to the fixed 0.16.3 version. +# The advisory relates to IterMut's Stacked Borrows violation, which does +# not affect our usage as we don't use IterMut directly. +# Tracked: https://github.com/near/nearcore/issues/XXXXX (upstream) +ignore = ["RUSTSEC-2026-0002"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeddbc80a..faf6e85cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,8 @@ jobs: with: cache: false - name: Install cargo-audit - run: cargo install cargo-audit --version "^0.21" --locked + # Require 0.22+ for CVSS 4.0 support (advisory-db now contains CVSS 4.0 entries) + run: cargo install cargo-audit --version "^0.22" --locked - uses: rustsec/audit-check@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 20fed84eb..8f501ab58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -953,6 +953,35 @@ dependencies = [ "serde_with", ] +[[package]] +name = "defuse-escrow-proxy" +version = "0.1.0" +dependencies = [ + "anyhow", + "defuse", + "defuse-admin-utils", + "defuse-auth-call", + "defuse-borsh-utils", + "defuse-controller", + "defuse-core", + "defuse-crypto", + "defuse-deadline", + "defuse-escrow-swap", + "defuse-fees", + "defuse-near-utils", + "defuse-nep245", + "defuse-oneshot-condvar", + "defuse-sandbox", + "defuse-token-id", + "futures", + "near-contract-standards", + "near-sdk", + "rand 0.9.2", + "serde_json", + "serde_with", + "tokio", +] + [[package]] name = "defuse-escrow-swap" version = "0.1.0" @@ -1044,6 +1073,19 @@ dependencies = [ "bnum", ] +[[package]] +name = "defuse-oneshot-condvar" +version = "0.1.0" +dependencies = [ + "defuse-auth-call", + "defuse-near-utils", + "defuse-nep245", + "impl-tools", + "near-sdk", + "serde_with", + "thiserror 2.0.17", +] + [[package]] name = "defuse-poa-factory" version = "0.1.0" @@ -1082,17 +1124,23 @@ version = "0.1.0" dependencies = [ "anyhow", "defuse-crypto", + "defuse-escrow-proxy", + "defuse-escrow-swap", "defuse-nep245", "defuse-nep413", + "defuse-oneshot-condvar", "defuse-randomness", "futures", "impl-tools", + "libc", "near-api", "near-contract-standards", - "near-openapi-types", + "near-openapi-client", + "near-openapi-types 0.6.0", "near-sandbox", "near-sdk", "rstest", + "serde_json", "thiserror 2.0.17", "tokio", "tracing", @@ -1145,13 +1193,17 @@ dependencies = [ "bnum", "chrono", "defuse", + "defuse-core", + "defuse-escrow-proxy", "defuse-escrow-swap", "defuse-near-utils", + "defuse-oneshot-condvar", "defuse-poa-factory", "defuse-randomness", "defuse-sandbox", "defuse-serde-utils", "defuse-test-utils", + "defuse-token-id", "derive_more", "futures", "hex", @@ -2435,6 +2487,9 @@ version = "0.1.0" dependencies = [ "defuse", "defuse-nep245", + "defuse-sandbox", + "futures", + "near-contract-standards", "near-sdk", "near-workspaces", "serde_json", @@ -2525,9 +2580,9 @@ dependencies = [ [[package]] name = "near-api" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a87920038b8a0eb5b4c91691651ce235810beb1f3a8626596b303b82750b75" +checksum = "321d2778c3cfb314cf6410376788a17b5c34f6154ee61bcd1579748edda454bb" dependencies = [ "async-trait", "base64 0.22.1", @@ -2551,9 +2606,9 @@ dependencies = [ [[package]] name = "near-api-types" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a5ca7d481bf75b61afb8552c8504df5d76f0a6c3e47482f498f2d65c349f8b" +checksum = "b4740cd9996fda91dd1cc9034528f0e987ac2c2266f15c6459df7a595e11eb71" dependencies = [ "base64 0.22.1", "borsh", @@ -2562,7 +2617,7 @@ dependencies = [ "near-abi", "near-account-id", "near-gas", - "near-openapi-types", + "near-openapi-types 0.7.0", "near-token", "primitive-types", "secp256k1", @@ -2704,14 +2759,14 @@ dependencies = [ [[package]] name = "near-openapi-client" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b0e00ba3bdba682deb964ac5db504558aea3c9434b521ad0b803b7f3c06dcf" +checksum = "c6d2a3dd4e669e14cdd0748b449d7539d0b0fefba17ed8e2d1404a0826908630" dependencies = [ "bytes", "chrono", "futures-core", - "near-openapi-types", + "near-openapi-types 0.7.0", "progenitor-client", "reqwest", "serde", @@ -2735,6 +2790,23 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "near-openapi-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0c9eaf432ddedd9409d6b1477b49e94679c077adbadce173292f759454afc7" +dependencies = [ + "bs58 0.5.1", + "chrono", + "near-account-id", + "near-gas", + "near-token", + "serde", + "serde_json", + "strum_macros 0.27.2", + "thiserror 2.0.17", +] + [[package]] name = "near-parameters" version = "0.34.1" diff --git a/Cargo.toml b/Cargo.toml index d7ba8bb0c..1b0330f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "defuse", "erc191", "escrow-swap", + "escrow-proxy", "fees", "io-utils", "map-utils", @@ -33,6 +34,7 @@ members = [ "tip191", "token-id", "ton-connect", + "oneshot-condvar", "wnear", ] default-members = ["defuse"] @@ -56,6 +58,7 @@ defuse-deadline.path = "deadline" defuse-decimal.path = "decimal" defuse-erc191.path = "erc191" defuse-escrow-swap.path = "escrow-swap" +defuse-escrow-proxy.path = "escrow-proxy" defuse-fees.path = "fees" defuse-io-utils.path = "io-utils" defuse-map-utils.path = "map-utils" @@ -70,6 +73,7 @@ defuse-poa-token.path = "poa-token" defuse-sandbox.path = "sandbox" defuse-sep53.path = "sep53" defuse-serde-utils.path = "serde-utils" +defuse-oneshot-condvar.path = "oneshot-condvar" defuse-tip191.path = "tip191" defuse-token-id = { path = "token-id", default-features = false } defuse-ton-connect = { path = "ton-connect", default-features = false, features = [ @@ -82,8 +86,9 @@ defuse-test-utils.path = "test-utils" cargo-near-build = "0.9.0" near-account-id = "2.4.0" -near-api = "0.8.2" -near-api-types = "0.8.2" +near-api = "0.8.3" +near-api-types = "0.8.3" +near-openapi-client = "0.7" near-openapi-types = "0.6.0" near-contract-standards = "5.24" near-crypto = "0.34.1" diff --git a/Makefile.toml b/Makefile.toml index a2dc45bab..8c9e240f0 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -11,6 +11,12 @@ POA_TOKEN_WASM_NO_REGISTRATION_WASM = "${POA_TOKEN_WITH_NO_REGISTRATION_DIR}/def MT_RECEIVER_STUB_DIR = "${TARGET_DIR}/multi-token-receiver-stub" MT_RECEIVER_STUB_WASM = "${MT_RECEIVER_STUB_DIR}/multi_token_receiver_stub.wasm" +ESCROW_PROXY_DIR = "${TARGET_DIR}/" +ESCROW_PROXY_WASM = "${ESCROW_PROXY_DIR}/defuse_escrow_proxy.wasm" + +ONESHOT_CONDVAR_DIR = "${TARGET_DIR}/" +ONESHOT_CONDVAR_WASM = "${ONESHOT_CONDVAR_DIR}/defuse_oneshot_condvar.wasm" + [tasks.default] alias = "build" @@ -25,6 +31,8 @@ dependencies = [ "build-escrow-swap", "build-poa-factory", "build-poa-token-no-registration", + "build-escrow-proxy", + "build-oneshot-condvar", "contract-stats", "build-multi-token-receiver-stub", ] @@ -114,6 +122,40 @@ args = [ "--no-embed-abi", ] +[tasks.build-escrow-proxy] +dependencies = ["add-cache-dir-tag"] +command = "cargo" +args = [ + "near", + "build", + "non-reproducible-wasm", + "--locked", + "--manifest-path", + "./escrow-proxy/Cargo.toml", + "--features", + "abi,contract,escrow-swap", + "--out-dir", + "${ESCROW_PROXY_DIR}", + "--no-embed-abi", +] + +[tasks.build-oneshot-condvar] +dependencies = ["add-cache-dir-tag"] +command = "cargo" +args = [ + "near", + "build", + "non-reproducible-wasm", + "--locked", + "--manifest-path", + "./oneshot-condvar/Cargo.toml", + "--features", + "abi,contract,auth-call", + "--out-dir", + "${ONESHOT_CONDVAR_DIR}", + "--no-abi", +] + # ============================================================================ # Build reproducible tasks # ============================================================================ diff --git a/defuse/Cargo.toml b/defuse/Cargo.toml index baad2c447..1ae0791d4 100644 --- a/defuse/Cargo.toml +++ b/defuse/Cargo.toml @@ -69,6 +69,7 @@ contract = [ sandbox = [ "arbitrary", + "contract", "dep:defuse-sandbox", "dep:defuse-test-utils", ] diff --git a/defuse/src/contract/tokens/mod.rs b/defuse/src/contract/tokens/mod.rs index b3d76d407..922886a8d 100644 --- a/defuse/src/contract/tokens/mod.rs +++ b/defuse/src/contract/tokens/mod.rs @@ -4,7 +4,7 @@ mod nep245; use super::Contract; use defuse_core::{DefuseError, Result, token_id::TokenId}; -use defuse_near_utils::{Lock, UnwrapOrPanic}; +use defuse_near_utils::{Lock, UnwrapOrPanic, max_list_u128_json_len}; use defuse_nep245::{MtBurnEvent, MtEvent, MtMintEvent}; use itertools::{Either, Itertools}; use near_sdk::{AccountId, AccountIdRef, Gas, env, json_types::U128, serde_json}; @@ -152,7 +152,7 @@ impl Contract { let tokens_count = tokens_iter.len(); let requested_refunds = - env::promise_result_checked(0, Self::mt_on_transfer_max_result_len(tokens_count)) + env::promise_result_checked(0, max_list_u128_json_len(tokens_count)) .ok() .and_then(|value| serde_json::from_slice::>(&value).ok()) .filter(|refunds| refunds.len() == tokens_count); @@ -211,16 +211,6 @@ impl Contract { MtEvent::MtBurn([burn_event].as_slice().into()).emit(); } } - - const fn mt_on_transfer_max_result_len(amounts_count: usize) -> usize { - // we allow at most one newline char and up to 8 spaces/tabs if the JSON was prettified - const MAX_LEN_PER_AMOUNT: usize = - " \"+340282366920938463463374607431768211455\",\n".len(); // u128::MAX - - amounts_count - .saturating_mul(MAX_LEN_PER_AMOUNT) - .saturating_add("[\n]".len()) - } } const MAX_TOKEN_ID_LEN: usize = 127; diff --git a/defuse/src/contract/tokens/nep245/resolver.rs b/defuse/src/contract/tokens/nep245/resolver.rs index 9ad8f72d0..75b4ec0be 100644 --- a/defuse/src/contract/tokens/nep245/resolver.rs +++ b/defuse/src/contract/tokens/nep245/resolver.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use defuse_near_utils::{Lock, UnwrapOrPanic, UnwrapOrPanicError}; +use defuse_near_utils::{Lock, UnwrapOrPanic, UnwrapOrPanicError, max_list_u128_json_len}; use defuse_nep245::{ ClearedApproval, MtEventEmit, MtTransferEvent, TokenId, resolver::MultiTokenResolver, }; @@ -27,12 +27,11 @@ impl MultiTokenResolver for Contract { "invalid args" ); - let mut refunds = - env::promise_result_checked(0, Self::mt_on_transfer_max_result_len(amounts.len())) - .ok() - .and_then(|value| serde_json::from_slice::>(&value).ok()) - .filter(|refund| refund.len() == amounts.len()) - .unwrap_or_else(|| amounts.clone()); + let mut refunds = env::promise_result_checked(0, max_list_u128_json_len(amounts.len())) + .ok() + .and_then(|value| serde_json::from_slice::>(&value).ok()) + .filter(|refund| refund.len() == amounts.len()) + .unwrap_or_else(|| amounts.clone()); let sender_id = previous_owner_ids.first().cloned().unwrap_or_panic(); diff --git a/defuse/src/contract/tokens/nep245/withdraw.rs b/defuse/src/contract/tokens/nep245/withdraw.rs index 634a6fbb5..e3a0311dd 100644 --- a/defuse/src/contract/tokens/nep245/withdraw.rs +++ b/defuse/src/contract/tokens/nep245/withdraw.rs @@ -12,7 +12,7 @@ use defuse_core::{ intents::tokens::MtWithdraw, token_id::{nep141::Nep141TokenId, nep245::Nep245TokenId}, }; -use defuse_near_utils::{UnwrapOrPanic, UnwrapOrPanicError}; +use defuse_near_utils::{UnwrapOrPanic, UnwrapOrPanicError, max_list_u128_json_len}; use defuse_nep245::ext_mt_core; use defuse_wnear::{NEAR_WITHDRAW_GAS, ext_wnear}; use near_contract_standards::storage_management::ext_storage_management; @@ -208,34 +208,33 @@ impl MultiTokenWithdrawResolver for Contract { "invalid args" ); - let mut used = - env::promise_result_checked(0, Self::mt_on_transfer_max_result_len(amounts.len())) - .map_or_else( - |_err| { - if is_call { - // do not refund on failed `mt_batch_transfer_call` due to - // NEP-141 vulnerability: `mt_resolve_transfer` fails to - // read result of `mt_on_transfer` due to insufficient gas - amounts.clone() - } else { - vec![U128(0); amounts.len()] - } - }, - |value| { - if is_call { - // `mt_batch_transfer_call` returns successfully transferred amounts - serde_json::from_slice::>(&value) - .ok() - .filter(|used| used.len() == amounts.len()) - .unwrap_or_else(|| vec![U128(0); amounts.len()]) - } else if value.is_empty() { - // `mt_batch_transfer` returns empty result on success - amounts.clone() - } else { - vec![U128(0); amounts.len()] - } - }, - ); + let mut used = env::promise_result_checked(0, max_list_u128_json_len(amounts.len())) + .map_or_else( + |_err| { + if is_call { + // do not refund on failed `mt_batch_transfer_call` due to + // NEP-141 vulnerability: `mt_resolve_transfer` fails to + // read result of `mt_on_transfer` due to insufficient gas + amounts.clone() + } else { + vec![U128(0); amounts.len()] + } + }, + |value| { + if is_call { + // `mt_batch_transfer_call` returns successfully transferred amounts + serde_json::from_slice::>(&value) + .ok() + .filter(|used| used.len() == amounts.len()) + .unwrap_or_else(|| vec![U128(0); amounts.len()]) + } else if value.is_empty() { + // `mt_batch_transfer` returns empty result on success + amounts.clone() + } else { + vec![U128(0); amounts.len()] + } + }, + ); self.deposit( sender_id, diff --git a/escrow-proxy/Cargo.toml b/escrow-proxy/Cargo.toml new file mode 100644 index 000000000..06368930a --- /dev/null +++ b/escrow-proxy/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "defuse-escrow-proxy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[dependencies] +defuse-admin-utils.workspace = true +defuse-auth-call.workspace = true +defuse-controller.workspace = true +defuse-crypto = { workspace = true, features = ["serde"] } +defuse-deadline.workspace = true +defuse-borsh-utils.workspace = true +defuse-escrow-swap = { workspace = true, optional = true } +defuse-fees.workspace = true +defuse-near-utils.workspace = true +defuse-nep245.workspace = true +defuse-token-id.workspace = true +defuse-oneshot-condvar = { workspace = true, features = ["auth-call"] } +serde_json.workspace = true + +near-contract-standards.workspace = true +near-sdk = { workspace = true, features = ["unstable", "deterministic-account-ids"] } +serde_with.workspace = true + +[dev-dependencies] +# Dependencies needed for examples only +defuse = { workspace = true, features = ["sandbox"] } +defuse-core.workspace = true +defuse-crypto.workspace = true +defuse-sandbox = { workspace = true, features = ["escrow-swap"] } +anyhow.workspace = true +futures.workspace = true +rand.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + +[features] +default = ["nep141", "nep245", "escrow-swap"] + +contract = [] +escrow-swap = ["dep:defuse-escrow-swap"] +nep141 = ["defuse-token-id/nep141"] +nep245 = ["defuse-token-id/nep245"] + +abi = ["defuse-crypto/abi", "defuse-token-id/abi", "defuse-deadline/abi", "defuse-oneshot-condvar/abi", "defuse-escrow-swap?/abi"] + +[[example]] +name = "escrow-swap-demo" diff --git a/escrow-proxy/examples/escrow-swap-demo.rs b/escrow-proxy/examples/escrow-swap-demo.rs new file mode 100644 index 000000000..cb2f3c1a8 --- /dev/null +++ b/escrow-proxy/examples/escrow-swap-demo.rs @@ -0,0 +1,495 @@ +//! Example demonstrating escrow swap flow with proxy authorization on NEAR testnet. +//! +//! This example shows how to: +//! 1. Connect to NEAR testnet using environment credentials +//! 2. Deploy global escrow-swap and oneshot-condvar contracts +//! 3. Deploy escrow-proxy contract +//! 4. Create solver and solverbus accounts +//! 5. Build and sign transfer + `auth_call` intents for `execute_intents` +//! +//! Usage: +//! USER=your-account.testnet PKEY=ed25519:... cargo run --example escrow-swap-demo --features test-utils +//! +//! Note: This example builds and executes intents on testnet. + +use defuse::sandbox_ext::signer::DefuseSigner; +use defuse_core::Nonce; +use defuse_core::intents::{DefuseIntents, Intent}; +use defuse_core::payload::multi::MultiPayload; +use defuse_oneshot_condvar::storage::StateInit as CondVarStateInit; +use defuse_token_id::nep245::Nep245TokenId; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::str::FromStr; +use std::time::Duration; + +use anyhow::Result; +use defuse_core::amounts::Amounts; +use defuse_core::intents::tokens::{NotifyOnTransfer, Transfer}; +use defuse_deadline::Deadline; +use defuse_escrow_proxy::TransferMessage as ProxyTransferMessage; +use defuse_escrow_swap::ContractStorage as EscrowContractStorage; +use defuse_escrow_swap::Params; +use defuse_escrow_swap::action::{ + FillAction, TransferAction, TransferMessage as EscrowTransferMessage, +}; +use defuse_escrow_swap::decimal::UD128; +use defuse_oneshot_condvar::CondVarContext; +use defuse_sandbox::api::{NetworkConfig, SecretKey, Signer}; +use defuse_sandbox::tx::FnCallBuilder; +use defuse_sandbox::{Account, MtViewExt, SigningAccount}; +use defuse_token_id::TokenId; +use defuse_token_id::nep141::Nep141TokenId; +use near_sdk::json_types::U128; +use near_sdk::serde_json::json; +use near_sdk::state_init::{StateInit, StateInitV1}; +use near_sdk::{AccountId, Gas, GlobalContractId, NearToken}; +use rand::{Rng, distr::Alphanumeric}; + +// Placeholder constants - replace with actual testnet values +const VERIFIER_CONTRACT: &str = "intents.nearseny.testnet"; + +const SRC_NEP245_TOKEN_ID: &str = "src-token.omft.nearseny.testnet"; +const DST_NEP245_TOKEN_ID: &str = "dst-token.omft.nearseny.testnet"; +const DEFUSE_INSTANCE: &str = "intents.nearseny.testnet"; + +const ONESHOT_CONDVAR_GLOBAL_REF_ID: &str = "test2.pityjllk.testnet"; +const ESCROW_GLOBAL_REF_ID: &str = "escrowswap.pityjllk.testnet"; + +// NOTE: +// near contract deploy escrowproxy.pityjllk.testnet use-file /Users/mat/intents/res/defuse_escrow_proxy.wasm with-init-call new json-args '{"roles":{"super_admins":["pityjllk.testnet"],"admins":{},"grantees":{}},"config":{"per_fill_contract_id":"test2.pityjllk.testnet","escrow_swap_contract_id":"escrowswap.pityjllk.testnet","auth_contract":"intents.nearseny.testnet","auth_collee":"pityjllk.testnet"}}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +const PROXY: &str = "escrowproxy.pityjllk.testnet"; + +/// Derive a new ED25519 secret key from an account ID and derivation path +/// using a deterministic derivation based on the account ID and derivation info. +fn derive_secret_key(account_id: &AccountId, derivation_info: &str) -> SecretKey { + use defuse_sandbox::api::{CryptoHash, types::crypto::secret_key::ED25519SecretKey}; + + // Hash account ID with derivation info to create deterministic seed + let mut seed_data = Vec::new(); + seed_data.extend_from_slice(account_id.as_str().as_bytes()); + seed_data.extend_from_slice(b":"); + seed_data.extend_from_slice(derivation_info.as_bytes()); + + // Use CryptoHash to create a deterministic 32-byte seed + let hash = CryptoHash::hash(&seed_data); + let derived_bytes: [u8; 32] = hash.0; + + SecretKey::ED25519(ED25519SecretKey::from_secret_key(derived_bytes)) +} + +/// Create a subaccount with a deterministically derived key from the parent account ID. +fn create_subaccount_with_derived_key( + root: &SigningAccount, + prefix: &str, +) -> Result { + let random_suffix: String = rand::rng() + .sample_iter(Alphanumeric) + .take(6) + .map(char::from) + .collect(); + let subaccount_name = format!("{}-{}", prefix, random_suffix.to_lowercase()); + let derived_secret = derive_secret_key(root.id(), &subaccount_name); + let derived_signer = Signer::from_secret_key(derived_secret)?; + + let subaccount = root.sub_account(&subaccount_name).unwrap(); + Ok(SigningAccount::new(subaccount, derived_signer)) +} + +// === Inline helper functions (previously from DefuseAccountExt) === + +/// Add a public key to an account in the defuse contract. +async fn defuse_add_public_key( + account: &SigningAccount, + defuse: &Account, + public_key: defuse_crypto::PublicKey, +) -> anyhow::Result<()> { + account + .tx(defuse.id().clone()) + .function_call( + FnCallBuilder::new("add_public_key") + .json_args(json!({ "public_key": public_key })) + .with_gas(Gas::from_tgas(50)), + ) + .await?; + Ok(()) +} + +/// Check if an account has a specific public key registered in defuse. +async fn defuse_has_public_key( + defuse: &Account, + account_id: &AccountId, + public_key: &defuse_crypto::PublicKey, +) -> anyhow::Result { + defuse + .call_view_function_json( + "has_public_key", + json!({ + "account_id": account_id, + "public_key": public_key, + }), + ) + .await +} + +/// Execute signed intents on the defuse contract. +async fn execute_signed_intents( + account: &SigningAccount, + defuse: &Account, + payloads: &[MultiPayload], +) -> anyhow::Result<()> { + // Note: RPC may return parsing error but the tx succeeds + account + .tx(defuse.id().clone()) + .function_call( + FnCallBuilder::new("execute_intents") + .json_args(json!({ "signed": payloads })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + Ok(()) +} + +/// Fund a subaccount on-chain, register its public key in the verifier contract, +/// and verify the registration succeeded. +/// +/// This function: +/// 1. Creates the account on-chain with 0.1 NEAR and the derived public key +/// 2. Registers the derived public key in the verifier (defuse) contract +/// 3. Queries the verifier to confirm the public key was registered correctly +async fn fund_and_register_subaccount( + root: &SigningAccount, + subaccount: &SigningAccount, + defuse: &Account, +) -> Result<()> { + let pubkey = subaccount.signer().get_public_key().await?; + + // 1. Create the account on-chain with 0.1 NEAR and the derived public key + root.tx(subaccount.id().clone()) + .create_account() + .transfer(NearToken::from_millinear(100)) // 0.1 NEAR + .add_full_access_key(pubkey) + .await?; + + // 2. Register the public key in the verifier contract + let defuse_pubkey: defuse_crypto::PublicKey = pubkey.into(); + defuse_add_public_key(subaccount, defuse, defuse_pubkey).await?; + + // 3. Verify registration by querying has_public_key + let has_key = defuse_has_public_key(defuse, subaccount.id(), &defuse_pubkey).await?; + assert!( + has_key, + "Public key registration failed for {}", + subaccount.id() + ); + println!( + " Verified: {} public key registered in verifier", + subaccount.id() + ); + + Ok(()) +} + +/// Register account's public key in defuse if not already registered. +async fn register_root_pkey_in_defuse(account: &SigningAccount, defuse: &Account) -> Result<()> { + let pubkey = account.signer().get_public_key().await?; + let defuse_pubkey: defuse_crypto::PublicKey = pubkey.into(); + let has_key = defuse_has_public_key(defuse, account.id(), &defuse_pubkey).await?; + if has_key { + println!("{} public key already registered in defuse", account.id()); + } else { + println!( + "{} public key NOT registered - registering...", + account.id() + ); + defuse_add_public_key(account, defuse, defuse_pubkey).await?; + println!("{} public key registered", account.id()); + } + Ok(()) +} + +#[tokio::main] +#[allow(clippy::too_many_lines)] +async fn main() -> Result<()> { + println!("=== Escrow Swap Demo (Testnet) ===\n"); + + // 1. Read environment variables + let user = std::env::var("USER").map_err(|_| anyhow::anyhow!("USER env var not set"))?; + let pkey = std::env::var("PKEY").map_err(|_| anyhow::anyhow!("PKEY env var not set"))?; + + println!("Using account: {user}"); + let network_config = NetworkConfig::testnet(); + println!("Network: testnet"); + + // 3. Create SigningAccount from credentials + let secret_key: SecretKey = pkey.parse()?; + let signer = Signer::from_secret_key(secret_key.clone())?; + let root = SigningAccount::new( + Account::new(user.parse::()?, network_config.clone()), + signer, + ); + let proxy: AccountId = PROXY.parse().unwrap(); + + let src_token: TokenId = Nep141TokenId::from_str(SRC_NEP245_TOKEN_ID).unwrap().into(); + let dst_token: TokenId = Nep141TokenId::from_str(DST_NEP245_TOKEN_ID).unwrap().into(); + let defuse = Account::new( + DEFUSE_INSTANCE.parse::()?, + network_config.clone(), + ); + + // 4. Derive subaccount keys (deterministic, no network calls) + println!("\n--- Deriving Subaccount Keys ---"); + let maker_signing = create_subaccount_with_derived_key(&root, "maker")?; + let maker_pubkey = maker_signing.signer().get_public_key().await?; + println!("Maker: {} (pubkey: {})", maker_signing.id(), maker_pubkey); + let taker_signing = create_subaccount_with_derived_key(&root, "taker")?; + let taker_pubkey = taker_signing.signer().get_public_key().await?; + println!("Taker: {} (pubkey: {})", taker_signing.id(), taker_pubkey); + + // 5. Create accounts on-chain, fund them, and register public keys in verifier + let src_token_str = src_token.to_string(); + let dst_token_str = dst_token.to_string(); + let (src_token_balance, dst_token_balance) = futures::try_join!( + defuse.mt_balance_of(root.id(), &src_token_str), + defuse.mt_balance_of(root.id(), &dst_token_str) + ) + .unwrap(); + + assert!(src_token_balance > 0); + assert!(dst_token_balance > 0); + + println!("source token ( {src_token} ) : {src_token_balance}"); + println!("destination token ( {dst_token} ) : {dst_token_balance}"); + + let deadline = Deadline::timeout(Duration::from_secs(300)); + // ESCROW SWAP PARAMS + let escrow_params = Params { + maker: maker_signing.id().clone(), + src_token: Nep245TokenId::new( + VERIFIER_CONTRACT.parse::().unwrap(), + src_token.to_string(), + ) + .into(), + dst_token: Nep245TokenId::new( + VERIFIER_CONTRACT.parse::().unwrap(), + dst_token.to_string(), + ) + .into(), + price: UD128::ONE, + deadline, // 5 min + partial_fills_allowed: false, + refund_src_to: defuse_escrow_swap::OverrideSend::default(), + receive_dst_to: defuse_escrow_swap::OverrideSend::default(), + taker_whitelist: [proxy.clone()].into(), + protocol_fees: None, + integrator_fees: BTreeMap::new(), + auth_caller: None, + salt: rand::rng().random(), + }; + // Build state_init for deploying escrow-swap instance + let escrow_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(ESCROW_GLOBAL_REF_ID.parse().unwrap()), + data: EscrowContractStorage::init_state(&escrow_params).unwrap(), + }); + let escrow_instance_id = escrow_state_init.derive_account_id(); + println!("Escrow-swap instance ID: {escrow_instance_id}"); + let escrow_fund_msg = EscrowTransferMessage { + params: escrow_params.clone(), + action: TransferAction::Fund, + }; + let maker_transfer_intent = Transfer { + receiver_id: escrow_instance_id.clone(), + tokens: Amounts::new([(src_token.clone(), 1)].into()), + memo: None, + notification: Some( + NotifyOnTransfer::new(serde_json::to_string(&escrow_fund_msg).unwrap()) + .with_state_init(escrow_state_init.clone()), + ), + }; + + // TAKER TRANSFER INETNT + let escrow_fill_msg = EscrowTransferMessage { + params: escrow_params.clone(), + action: TransferAction::Fill(FillAction { + price: UD128::ONE, + deadline, + receive_src_to: defuse_escrow_swap::OverrideSend::default() + .receiver_id(taker_signing.id().clone()), + }), + }; + let proxy_msg = ProxyTransferMessage { + receiver_id: escrow_instance_id.clone(), // escrow instance id + salt: rand::rng().random(), + msg: serde_json::to_string(&escrow_fill_msg).unwrap(), + }; + let proxy_msg_json = serde_json::to_string(&proxy_msg)?; + let taker_transfer_intent = Transfer { + receiver_id: proxy, + tokens: Amounts::new([(dst_token.clone(), 1)].into()), + memo: None, + notification: Some(NotifyOnTransfer::new(proxy_msg_json.clone())), + }; + + // RELAY AUTH CALL INTENT + // The relay authorizes the taker's transfer by signing an AuthCall intent + // that deploys the oneshot-condvar instance with state matching the transfer context + let condvar_context = CondVarContext { + sender_id: Cow::Borrowed(taker_signing.id().as_ref()), + token_ids: Cow::Owned(vec![dst_token.to_string()]), + amounts: Cow::Owned(vec![U128(1)]), + salt: proxy_msg.salt, + // NOTE: authorizes particular notification from taker(solver) + msg: Cow::Borrowed(&proxy_msg_json), + }; + + // CondVarStateInit defines the state for the oneshot-condvar instance + let condvar_state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(ESCROW_GLOBAL_REF_ID.parse().unwrap()), + auth_contract: VERIFIER_CONTRACT.parse().unwrap(), + notifier_id: root.id().clone(), // relay account that signs the auth + authorizee: PROXY.parse().unwrap(), + msg_hash: condvar_context.hash(), + }; + + // Build state_init for deploying oneshot-condvar instance + let condvar_raw_state = + defuse_oneshot_condvar::storage::ContractStorage::init_state(condvar_state.clone()) + .unwrap(); + let condvar_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(ONESHOT_CONDVAR_GLOBAL_REF_ID.parse().unwrap()), + data: condvar_raw_state, + }); + let condvar_instance_id = condvar_state_init.derive_account_id(); + println!("OneshotCondVar instance ID: {condvar_instance_id}"); + + // Create AuthCall intent that deploys oneshot-condvar and authorizes the transfer + let relay_auth_call = defuse_core::intents::auth::AuthCall { + contract_id: condvar_instance_id.clone(), + state_init: Some(condvar_state_init), + msg: String::new(), + attached_deposit: NearToken::from_yoctonear(0), + min_gas: None, + }; + + // ROOT TRANSFER INTENTS + // Transfer src tokens from root to maker + let root_to_maker_transfer = Transfer { + receiver_id: maker_signing.id().clone(), + tokens: Amounts::new([(src_token.clone(), 1)].into()), + memo: None, + notification: None, + }; + + // Transfer dst tokens from root to taker + let root_to_taker_transfer = Transfer { + receiver_id: taker_signing.id().clone(), + tokens: Amounts::new([(dst_token.clone(), 1)].into()), + memo: None, + notification: None, + }; + + // === SIGN ALL INTENTS === + + println!("\n--- Signing Intents ---"); + + let maker_sends_funds_to_escrow = maker_signing + .sign_defuse_message( + defuse.id(), + Nonce::from(rand::rng().random::<[u8; 32]>()), + deadline, + DefuseIntents { + intents: vec![Intent::Transfer(maker_transfer_intent)], + }, + ) + .await; + + let taker_sends_funds_to_proxy = taker_signing + .sign_defuse_message( + defuse.id(), + Nonce::from(rand::rng().random::<[u8; 32]>()), + deadline, + DefuseIntents { + intents: vec![Intent::Transfer(taker_transfer_intent)], + }, + ) + .await; + + let root_sends_funds_to_maker_and_taker = root + .sign_defuse_message( + defuse.id(), + Nonce::from(rand::rng().random::<[u8; 32]>()), + deadline, + DefuseIntents { + intents: vec![ + Intent::Transfer(root_to_maker_transfer), + Intent::Transfer(root_to_taker_transfer), + ], + }, + ) + .await; + + let root_sends_auth_call = root + .sign_defuse_message( + defuse.id(), + Nonce::from(rand::rng().random::<[u8; 32]>()), + deadline, + DefuseIntents { + intents: vec![Intent::AuthCall(relay_auth_call)], + }, + ) + .await; + // EXECUTION + println!("\n--- Execution ---"); + + // Setup: Register public keys in defuse for all signers + register_root_pkey_in_defuse(&root, &defuse).await?; + fund_and_register_subaccount(&root, &maker_signing, &defuse).await?; + fund_and_register_subaccount(&root, &taker_signing, &defuse).await?; + + // Step 1: Root transfers funds to maker and taker + println!("\nStep 1: Root sends funds to maker and taker..."); + execute_signed_intents(&root, &defuse, &[root_sends_funds_to_maker_and_taker]).await?; + println!(" Done: maker and taker funded"); + + // Step 2: Maker sends funds to escrow (deploys escrow-swap instance) + println!("\nStep 2: Maker sends funds to escrow (deploys escrow-swap)..."); + execute_signed_intents(&root, &defuse, &[maker_sends_funds_to_escrow]).await?; + println!(" Done: escrow-swap instance deployed at {escrow_instance_id}"); + + // Step 3: Root sends auth_call + taker sends funds to proxy (atomically) + // This deploys transfer-auth instance and executes the fill through the proxy + println!("\nStep 3: Root sends auth_call + taker sends funds to proxy..."); + execute_signed_intents( + &root, + &defuse, + &[root_sends_auth_call, taker_sends_funds_to_proxy], + ) + .await?; + println!(" Done: oneshot-condvar deployed at {condvar_instance_id}"); + println!(" Done: taker filled the escrow through proxy"); + + println!("\n=== Escrow Swap Demo Complete ==="); + + // Query escrow-swap instance state + let escrow_account = Account::new(escrow_instance_id.clone(), network_config.clone()); + let escrow_state: defuse_escrow_swap::Storage = escrow_account + .call_view_function_json("escrow_view", serde_json::json!({})) + .await?; + println!( + " Escrow state: {}", + serde_json::to_string_pretty(&escrow_state).unwrap() + ); + + // Query oneshot-condvar instance state + let condvar_account = Account::new(condvar_instance_id.clone(), network_config.clone()); + let condvar_state: defuse_oneshot_condvar::storage::ContractStorage = condvar_account + .call_view_function_json("view", serde_json::json!({})) + .await?; + println!( + " OneshotCondVar state: {}", + serde_json::to_string_pretty(&condvar_state).unwrap() + ); + + Ok(()) +} diff --git a/escrow-proxy/src/contract/admin.rs b/escrow-proxy/src/contract/admin.rs new file mode 100644 index 000000000..59b28b105 --- /dev/null +++ b/escrow-proxy/src/contract/admin.rs @@ -0,0 +1,23 @@ +use defuse_admin_utils::full_access_keys::FullAccessKeys; +use near_sdk::{Promise, PublicKey, assert_one_yocto, env, near}; + +use super::Contract; +#[cfg(feature = "abi")] +use super::ContractExt; + +#[near] +impl FullAccessKeys for Contract { + #[payable] + fn add_full_access_key(&mut self, public_key: PublicKey) -> Promise { + self.assert_owner(); + assert_one_yocto(); + Promise::new(env::current_account_id()).add_full_access_key(public_key) + } + + #[payable] + fn delete_key(&mut self, public_key: PublicKey) -> Promise { + self.assert_owner(); + assert_one_yocto(); + Promise::new(env::current_account_id()).delete_key(public_key) + } +} diff --git a/escrow-proxy/src/contract/mod.rs b/escrow-proxy/src/contract/mod.rs new file mode 100644 index 000000000..e85b581f6 --- /dev/null +++ b/escrow-proxy/src/contract/mod.rs @@ -0,0 +1,109 @@ +mod admin; +mod tokens; +mod upgrade; +mod utils; + +#[cfg(feature = "escrow-swap")] +use defuse_escrow_swap::ext_escrow; +use defuse_near_utils::UnwrapOrPanicError; +use defuse_oneshot_condvar::CondVarContext; +#[cfg(feature = "escrow-swap")] +use near_sdk::state_init::{StateInit, StateInitV1}; +use near_sdk::{ + AccountId, CryptoHash, Gas, PanicOnDefault, Promise, env, json_types::U128, near, require, +}; + +use crate::EscrowProxy; +use crate::message::TransferMessage; +use crate::state::ProxyConfig; +#[cfg(feature = "escrow-swap")] +use defuse_escrow_swap::Params as EscrowParams; + +#[near(contract_state)] +#[derive(PanicOnDefault)] +pub struct Contract { + config: ProxyConfig, +} + +#[near] +impl Contract { + #[init] + #[must_use] + #[allow(clippy::use_self)] + pub fn new(config: ProxyConfig) -> Contract { + Self { config } + } + + fn assert_owner(&self) { + require!( + env::predecessor_account_id() == self.config.owner, + "Only owner can call this method" + ); + } +} + +#[near] +impl EscrowProxy for Contract { + /// Returns proxy configuration + fn config(&self) -> &ProxyConfig { + &self.config + } + + /// Calculates oneshot condvar context hash that is required to derive condvar contract + /// instance address + fn context_hash(&self, context: CondVarContext<'static>) -> CryptoHash { + context.hash() + } + + /// Calculates oneshot condvar contract instance address, helper function for integration + /// purposes, and easy calculation of oneshot condvar contract instance address in case of + /// need for direct authorization using OneshotCondvar::cv_notify_one + /// taker_id: The account id of the taker + /// token_ids: The token ids of the tokens being transferred + /// amounts: The amounts of the tokens being transferred + /// msg: escrow proxy transfer message + fn oneshot_address( + &self, + taker_id: AccountId, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> AccountId { + use std::borrow::Cow; + let transfer_message: TransferMessage = msg.parse().unwrap_or_panic_display(); + let context_hash = CondVarContext { + sender_id: Cow::Owned(taker_id), + token_ids: Cow::Owned(token_ids), + amounts: Cow::Owned(amounts), + salt: transfer_message.salt, + msg: Cow::Borrowed(&msg), + } + .hash(); + let auth_contract_state_init = + self.get_deterministic_transfer_auth_state_init(context_hash); + auth_contract_state_init.derive_account_id() + } +} + +#[cfg(feature = "escrow-swap")] +#[near] +impl Contract { + /// Calculates escrow contract instance address + pub fn escrow_address(&self, params: &EscrowParams) -> AccountId { + let raw_state = defuse_escrow_swap::ContractStorage::init_state(params) + .unwrap_or_else(|e| env::panic_str(&format!("Invalid escrow params: {e}"))); + let state_init = StateInit::V1(StateInitV1 { + code: self.config.escrow_swap_contract_id.clone(), + data: raw_state, + }); + state_init.derive_account_id() + } + + pub fn cancel_escrow(&self, params: EscrowParams) -> Promise { + self.assert_owner(); + let escrow_address = self.escrow_address(¶ms); + ext_escrow::ext(escrow_address) + .with_static_gas(Gas::from_tgas(50)) + .es_close(params) + } +} diff --git a/escrow-proxy/src/contract/tokens/mod.rs b/escrow-proxy/src/contract/tokens/mod.rs new file mode 100644 index 000000000..c4190ad58 --- /dev/null +++ b/escrow-proxy/src/contract/tokens/mod.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "nep141")] +mod nep141; +#[cfg(feature = "nep245")] +mod nep245; diff --git a/escrow-proxy/src/contract/tokens/nep141.rs b/escrow-proxy/src/contract/tokens/nep141.rs new file mode 100644 index 000000000..f2d8b2877 --- /dev/null +++ b/escrow-proxy/src/contract/tokens/nep141.rs @@ -0,0 +1,81 @@ +use defuse_near_utils::{UnwrapOrPanicError, promise_result_U128, promise_result_bool}; +use near_contract_standards::fungible_token::{core::ext_ft_core, receiver::FungibleTokenReceiver}; +use near_sdk::{AccountId, Gas, NearToken, PromiseOrValue, env, json_types::U128, near}; + +use crate::contract::{Contract, ContractExt}; +use crate::message::TransferMessage; + +#[near] +impl FungibleTokenReceiver for Contract { + fn ft_on_transfer( + &mut self, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + // For FT, the token is identified by the predecessor (FT contract) + let token_contract = env::predecessor_account_id(); + let token_ids = vec![token_contract.to_string()]; + let amounts = vec![amount]; + let transfer_message: TransferMessage = msg.parse().unwrap_or_panic_display(); + let cv_wait = self.create_cv_wait_cross_contract_call( + &sender_id, + &token_ids, + &amounts, + transfer_message.salt, + &msg, + ); + + PromiseOrValue::Promise( + cv_wait.then( + Self::ext(env::current_account_id()) + .with_unused_gas_weight(1) + .check_authorization_and_forward_ft( + token_contract, + transfer_message.receiver_id, + amount, + transfer_message.msg, + ), + ), + ) + } +} + +#[near] +impl Contract { + #[private] + pub fn check_authorization_and_forward_ft( + &self, + token_contract: AccountId, + escrow_address: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + if !promise_result_bool(0).unwrap_or(false) { + near_sdk::env::panic_str("Authorization failed or timed out, refunding"); + } + + PromiseOrValue::Promise( + ext_ft_core::ext(token_contract) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .with_static_gas(Gas::from_tgas(50)) + .ft_transfer_call( + escrow_address, + amount, + Some("proxy forward".to_string()), // memo + msg, + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(10)) + .resolve_ft_transfer(amount), + ), + ) + } + + #[private] + pub fn resolve_ft_transfer(&self, original_amount: U128) -> U128 { + let used = promise_result_U128(0).unwrap_or_default(); + U128(original_amount.0.saturating_sub(used.0)) + } +} diff --git a/escrow-proxy/src/contract/tokens/nep245.rs b/escrow-proxy/src/contract/tokens/nep245.rs new file mode 100644 index 000000000..7f1ffedf0 --- /dev/null +++ b/escrow-proxy/src/contract/tokens/nep245.rs @@ -0,0 +1,90 @@ +use defuse_near_utils::{UnwrapOrPanicError, promise_result_bool, promise_result_vec_U128}; +use defuse_nep245::{ext_mt_core, receiver::MultiTokenReceiver}; +use near_sdk::{AccountId, Gas, NearToken, PromiseOrValue, env, json_types::U128, near}; + +use crate::contract::{Contract, ContractExt}; +use crate::message::TransferMessage; + +#[near] +impl MultiTokenReceiver for Contract { + #[allow(clippy::used_underscore_binding)] + fn mt_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_ids: Vec, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> PromiseOrValue> { + let _ = previous_owner_ids; + let token_contract = env::predecessor_account_id(); + let transfer_message: TransferMessage = msg.parse().unwrap_or_panic_display(); + let cv_wait = self.create_cv_wait_cross_contract_call( + &sender_id, + &token_ids, + &amounts, + transfer_message.salt, + &msg, + ); + + PromiseOrValue::Promise( + cv_wait.then( + Self::ext(env::current_account_id()) + .with_unused_gas_weight(1) + .check_authorization_and_forward_mt( + token_contract, + transfer_message.receiver_id, + token_ids, + amounts, + transfer_message.msg, + ), + ), + ) + } +} + +#[near] +impl Contract { + #[private] + pub fn check_authorization_and_forward_mt( + &self, + token_contract: AccountId, + escrow_address: AccountId, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> PromiseOrValue> { + if !promise_result_bool(0).unwrap_or(false) { + near_sdk::env::panic_str("Authorization failed or timed out, refunding"); + } + + PromiseOrValue::Promise( + ext_mt_core::ext(token_contract) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .mt_batch_transfer_call( + escrow_address, + token_ids, + amounts.clone(), + None, + Some("proxy".to_string()), + msg, + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(10)) + .resolve_mt_transfer(amounts), + ), + ) + } + + #[private] + pub fn resolve_mt_transfer(&self, original_amounts: Vec) -> Vec { + let used = promise_result_vec_U128(0, original_amounts.len()).unwrap_or_default(); + + original_amounts + .iter() + .zip(used.iter()) + .map(|(original, transferred)| U128(original.0.saturating_sub(transferred.0))) + .collect() + } +} diff --git a/escrow-proxy/src/contract/upgrade.rs b/escrow-proxy/src/contract/upgrade.rs new file mode 100644 index 000000000..66cfa21c4 --- /dev/null +++ b/escrow-proxy/src/contract/upgrade.rs @@ -0,0 +1,30 @@ +use defuse_controller::ControllerUpgradable; +use near_sdk::{Gas, Promise, assert_one_yocto, env, near}; + +use super::Contract; +#[cfg(feature = "abi")] +use super::ContractExt; + +const STATE_MIGRATE_DEFAULT_GAS: Gas = Gas::from_tgas(5); + +#[near] +impl ControllerUpgradable for Contract { + #[payable] + fn upgrade( + &mut self, + #[serializer(borsh)] code: Vec, + #[serializer(borsh)] state_migration_gas: Option, + ) -> Promise { + self.assert_owner(); + assert_one_yocto(); + + let p = Promise::new(env::current_account_id()).deploy_contract(code); + + Self::ext_on(p) + .with_static_gas(state_migration_gas.unwrap_or(STATE_MIGRATE_DEFAULT_GAS)) + .state_migrate() + } + + #[private] + fn state_migrate(&mut self) {} +} diff --git a/escrow-proxy/src/contract/utils.rs b/escrow-proxy/src/contract/utils.rs new file mode 100644 index 000000000..0b7eeb42a --- /dev/null +++ b/escrow-proxy/src/contract/utils.rs @@ -0,0 +1,65 @@ +use std::borrow::Cow; + +use defuse_near_utils::promise_result_bool; +use defuse_oneshot_condvar::{ + CondVarContext, WAIT_GAS, ext_oneshot_condvar, + storage::{ContractStorage, StateInit as CondVarStateInit}, +}; +use near_sdk::{ + AccountId, NearToken, Promise, env, + json_types::U128, + state_init::{StateInit, StateInitV1}, +}; + +use super::Contract; + +impl Contract { + pub(crate) fn get_deterministic_transfer_auth_state_init( + &self, + msg_hash: [u8; 32], + ) -> StateInit { + let state = CondVarStateInit { + escrow_contract_id: self.config.escrow_swap_contract_id.clone(), + auth_contract: self.config.auth_contract.clone(), + notifier_id: self.config.auth_collee.clone(), + authorizee: env::current_account_id(), + msg_hash, + }; + + StateInit::V1(StateInitV1 { + code: self.config.per_fill_contract_id.clone(), + data: ContractStorage::init_state(state).unwrap(), + }) + } + + /// Creates the authorization promise for token transfers. + /// Returns the cv_wait Promise. + pub(crate) fn create_cv_wait_cross_contract_call( + &self, + sender_id: &AccountId, + token_ids: &[defuse_nep245::TokenId], + amounts: &[U128], + salt: [u8; 32], + msg: &str, + ) -> Promise { + let context_hash = CondVarContext { + sender_id: Cow::Borrowed(sender_id), + token_ids: Cow::Borrowed(token_ids), + amounts: Cow::Borrowed(amounts), + salt, + msg: Cow::Borrowed(msg), + } + .hash(); + + let auth_contract_state_init = + self.get_deterministic_transfer_auth_state_init(context_hash); + let auth_contract_id = auth_contract_state_init.derive_account_id(); + let auth_call = Promise::new(auth_contract_id) + .state_init(auth_contract_state_init, NearToken::from_near(0)); + + ext_oneshot_condvar::ext_on(auth_call) + .with_static_gas(WAIT_GAS) + .with_unused_gas_weight(0) + .cv_wait() + } +} diff --git a/escrow-proxy/src/lib.rs b/escrow-proxy/src/lib.rs new file mode 100644 index 000000000..d41f32ab7 --- /dev/null +++ b/escrow-proxy/src/lib.rs @@ -0,0 +1,25 @@ +#[cfg(feature = "contract")] +mod contract; +mod message; +pub mod state; + +use near_sdk::ext_contract; + +pub use message::*; +pub use state::ProxyConfig; + +use defuse_oneshot_condvar::CondVarContext; +use near_sdk::{AccountId, CryptoHash, json_types::U128}; + +#[ext_contract(ext_escrow_proxy)] +pub trait EscrowProxy { + fn config(&self) -> &ProxyConfig; + fn context_hash(&self, context: CondVarContext<'static>) -> CryptoHash; + fn oneshot_address( + &self, + taker_id: AccountId, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> AccountId; +} diff --git a/escrow-proxy/src/message.rs b/escrow-proxy/src/message.rs new file mode 100644 index 000000000..6b5b93268 --- /dev/null +++ b/escrow-proxy/src/message.rs @@ -0,0 +1,20 @@ +use std::str::FromStr; + +use near_sdk::{AccountId, near}; + +#[near(serializers = [json])] +#[derive(Debug, Clone)] +pub struct TransferMessage { + pub receiver_id: AccountId, + pub salt: [u8; 32], + pub msg: String, +} + +impl FromStr for TransferMessage { + type Err = serde_json::Error; + + #[inline] + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} diff --git a/escrow-proxy/src/state.rs b/escrow-proxy/src/state.rs new file mode 100644 index 000000000..df9dfd793 --- /dev/null +++ b/escrow-proxy/src/state.rs @@ -0,0 +1,11 @@ +use near_sdk::{AccountId, GlobalContractId, near}; + +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyConfig { + pub owner: AccountId, + pub per_fill_contract_id: GlobalContractId, + pub escrow_swap_contract_id: GlobalContractId, + pub auth_contract: AccountId, + pub auth_collee: AccountId, +} diff --git a/escrow-swap/src/action.rs b/escrow-swap/src/action.rs index f794ff11b..d6aef0290 100644 --- a/escrow-swap/src/action.rs +++ b/escrow-swap/src/action.rs @@ -1,7 +1,7 @@ use derive_more::From; use near_sdk::near; -use crate::{Deadline, OverrideSend, Params, decimal::UD128}; +use crate::{DEFAULT_DEADLINE_SECS, Deadline, OverrideSend, Params, decimal::UD128}; #[near(serializers = [json])] #[derive(Debug, Clone)] @@ -31,3 +31,74 @@ pub struct FillAction { #[serde(default, skip_serializing_if = "crate::utils::is_default")] pub receive_src_to: OverrideSend, } + +pub struct FundMessageBuilder { + params: Params, +} + +impl FundMessageBuilder { + #[must_use] + pub const fn new(params: Params) -> Self { + Self { params } + } + + #[must_use] + pub fn build(self) -> TransferMessage { + TransferMessage { + params: self.params, + action: TransferAction::Fund, + } + } +} + +/// Builder for creating Fill transfer messages. +pub struct FillMessageBuilder { + params: Params, + price: Option, + deadline: Option, + receive_src_to: Option, +} + +impl FillMessageBuilder { + #[must_use] + pub const fn new(params: Params) -> Self { + Self { + params, + price: None, + deadline: None, + receive_src_to: None, + } + } + + #[must_use] + pub const fn with_price(mut self, price: UD128) -> Self { + self.price = Some(price); + self + } + + #[must_use] + pub const fn with_deadline(mut self, deadline: Deadline) -> Self { + self.deadline = Some(deadline); + self + } + + #[must_use] + pub fn with_receive_src_to(mut self, receive_src_to: OverrideSend) -> Self { + self.receive_src_to = Some(receive_src_to); + self + } + + #[must_use] + pub fn build(self) -> TransferMessage { + TransferMessage { + params: self.params.clone(), + action: TransferAction::Fill(FillAction { + price: self.price.unwrap_or(self.params.price), + deadline: self.deadline.unwrap_or_else(|| { + Deadline::timeout(std::time::Duration::from_secs(DEFAULT_DEADLINE_SECS)) + }), + receive_src_to: self.receive_src_to.unwrap_or_default(), + }), + } + } +} diff --git a/escrow-swap/src/contract/tokens/mod.rs b/escrow-swap/src/contract/tokens/mod.rs index 4c5866b1e..60987ec59 100644 --- a/escrow-swap/src/contract/tokens/mod.rs +++ b/escrow-swap/src/contract/tokens/mod.rs @@ -3,6 +3,7 @@ mod nep141; #[cfg(feature = "nep245")] mod nep245; +use defuse_near_utils::{MAX_U128_JSON_LEN, max_list_u128_json_len}; use near_sdk::{ AccountId, Gas, Promise, PromiseOrValue, PromiseResult, env, json_types::U128, near, serde_json, }; diff --git a/escrow-swap/src/state.rs b/escrow-swap/src/state.rs index 2f1a8eb4b..52a44574c 100644 --- a/escrow-swap/src/state.rs +++ b/escrow-swap/src/state.rs @@ -10,6 +10,10 @@ use serde_with::{DisplayFromStr, hex::Hex, serde_as}; use crate::{Deadline, Error, Result, decimal::UD128}; +pub const DEFAULT_DEADLINE_SECS: u64 = 360; +/// Default salt for test/example purposes. Not suitable for production. +pub const ZERO_SALT: [u8; 32] = [0u8; 32]; + #[near(serializers = [borsh, json])] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContractStorage( @@ -189,6 +193,131 @@ impl Params { } } +/// Builder for creating escrow swap parameters. +/// Takes (maker, `src_token`) and (takers, `dst_token`) tuples associating actors with their tokens. +#[derive(Debug, Clone)] +pub struct ParamsBuilder { + maker: AccountId, + src_token: TokenId, + takers: BTreeSet, + dst_token: TokenId, + salt: Option<[u8; 32]>, + price: Option, + partial_fills_allowed: Option, + deadline: Option, + refund_src_to: Option, + receive_dst_to: Option, + #[cfg(feature = "auth_call")] + auth_caller: Option, + protocol_fees: Option, + integrator_fees: BTreeMap, +} + +impl ParamsBuilder { + pub fn new( + (maker, src_token): (AccountId, TokenId), + (takers, dst_token): (impl IntoIterator, TokenId), + ) -> Self { + Self { + maker, + src_token, + takers: takers.into_iter().collect(), + dst_token, + salt: None, + price: None, + partial_fills_allowed: None, + deadline: None, + refund_src_to: None, + receive_dst_to: None, + #[cfg(feature = "auth_call")] + auth_caller: None, + protocol_fees: None, + integrator_fees: BTreeMap::new(), + } + } + + #[must_use] + pub const fn with_salt(mut self, salt: [u8; 32]) -> Self { + self.salt = Some(salt); + self + } + + #[must_use] + pub const fn with_price(mut self, price: crate::decimal::UD128) -> Self { + self.price = Some(price); + self + } + + #[must_use] + pub const fn with_partial_fills_allowed(mut self, allowed: bool) -> Self { + self.partial_fills_allowed = Some(allowed); + self + } + + #[must_use] + pub const fn with_deadline(mut self, deadline: crate::Deadline) -> Self { + self.deadline = Some(deadline); + self + } + + #[must_use] + pub fn with_refund_src_to(mut self, refund_src_to: OverrideSend) -> Self { + self.refund_src_to = Some(refund_src_to); + self + } + + #[must_use] + pub fn with_receive_dst_to(mut self, receive_dst_to: OverrideSend) -> Self { + self.receive_dst_to = Some(receive_dst_to); + self + } + + #[cfg(feature = "auth_call")] + #[must_use] + pub fn with_auth_caller(mut self, auth_caller: AccountId) -> Self { + self.auth_caller = Some(auth_caller); + self + } + + #[must_use] + pub fn with_protocol_fees(mut self, protocol_fees: ProtocolFees) -> Self { + self.protocol_fees = Some(protocol_fees); + self + } + + #[must_use] + pub fn with_integrator_fee(mut self, account_id: AccountId, fee: Pips) -> Self { + self.integrator_fees.insert(account_id, fee); + self + } + + /// Returns the takers whitelist. + pub const fn takers(&self) -> &BTreeSet { + &self.takers + } + + pub fn build(self) -> Params { + Params { + maker: self.maker, + src_token: self.src_token, + dst_token: self.dst_token, + price: self.price.unwrap_or(UD128::ONE), + deadline: self.deadline.unwrap_or_else(|| { + Deadline::timeout(std::time::Duration::from_secs(DEFAULT_DEADLINE_SECS)) + }), + partial_fills_allowed: self.partial_fills_allowed.unwrap_or(false), + refund_src_to: self.refund_src_to.unwrap_or_default(), + receive_dst_to: self.receive_dst_to.unwrap_or_default(), + taker_whitelist: self.takers, + protocol_fees: self.protocol_fees, + integrator_fees: self.integrator_fees, + #[cfg(feature = "auth_call")] + auth_caller: self.auth_caller, + salt: self.salt.unwrap_or(ZERO_SALT), + } + } +} + #[near(serializers = [borsh, json])] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProtocolFees { diff --git a/near-utils/src/promise.rs b/near-utils/src/promise.rs index 21611367b..830e113ad 100644 --- a/near-utils/src/promise.rs +++ b/near-utils/src/promise.rs @@ -1,4 +1,4 @@ -use near_sdk::Promise; +use near_sdk::{Promise, env, json_types::U128, serde_json}; pub trait PromiseExt: Sized { fn and_maybe(self, p: Option) -> Promise; @@ -10,3 +10,91 @@ impl PromiseExt for Promise { if let Some(p) = p { self.and(p) } else { self } } } + +pub const MAX_BOOL_JSON_LEN: usize = " false ".len(); +pub const MAX_U128_JSON_LEN: usize = " \"340282366920938463463374607431768211455\" ".len(); + +#[must_use] +pub const fn max_list_u128_json_len(count: usize) -> usize { + const MAX_LEN_PER_AMOUNT: usize = + " \"340282366920938463463374607431768211455\",\n".len(); + + count + .saturating_mul(MAX_LEN_PER_AMOUNT) + .saturating_add("[\n]".len()) +} + +#[inline] +#[must_use] +pub fn promise_result_bool(result_idx: u64) -> Option { + env::promise_result_checked(result_idx, MAX_BOOL_JSON_LEN) + .ok() + .and_then(|value| serde_json::from_slice::(&value).ok()) +} + +#[allow(non_snake_case)] +#[inline] +#[must_use] +pub fn promise_result_U128(result_idx: u64) -> Option { + env::promise_result_checked(result_idx, MAX_U128_JSON_LEN) + .ok() + .and_then(|value| serde_json::from_slice::(&value).ok()) +} + +#[allow(non_snake_case)] +#[inline] +#[must_use] +pub fn promise_result_vec_U128(result_idx: u64, expected_len: usize) -> Option> { + env::promise_result_checked(result_idx, max_list_u128_json_len(expected_len)) + .ok() + .and_then(|value| serde_json::from_slice::>(&value).ok()) + .filter(|v| v.len() == expected_len) +} + +#[cfg(test)] +mod tests { + use near_sdk::json_types::U128; + use rstest::rstest; + + use super::*; + + #[test] + fn test_max_bool_json_len() { + let prettified_false = serde_json::to_string_pretty(&false).unwrap(); + let prettified_true = serde_json::to_string_pretty(&true).unwrap(); + assert!(prettified_false.len() <= MAX_BOOL_JSON_LEN); + assert!(prettified_true.len() <= MAX_BOOL_JSON_LEN); + + let compact_false = serde_json::to_string(&false).unwrap(); + let compact_true = serde_json::to_string(&true).unwrap(); + assert!(compact_false.len() <= MAX_BOOL_JSON_LEN); + assert!(compact_true.len() <= MAX_BOOL_JSON_LEN); + } + + #[test] + fn test_max_u128_json_len() { + let max_val = U128(u128::MAX); + let prettified = serde_json::to_string_pretty(&max_val).unwrap(); + assert!(prettified.len() <= MAX_U128_JSON_LEN); + + let compact = serde_json::to_string(&max_val).unwrap(); + assert!(compact.len() <= MAX_U128_JSON_LEN); + } + + #[rstest] + #[case::len_0(0)] + #[case::len_1(1)] + #[case::len_2(2)] + #[case::len_5(5)] + #[case::len_10(10)] + #[case::len_100(100)] + fn test_max_list_u128_json_len(#[case] count: usize) { + let vec: Vec = vec![U128(u128::MAX); count]; + let prettified = serde_json::to_string_pretty(&vec).unwrap(); + let max_len = max_list_u128_json_len(count); + assert!(prettified.len() <= max_len); + + let compact = serde_json::to_string(&vec).unwrap(); + assert!(compact.len() <= max_len); + } +} diff --git a/oneshot-condvar/Cargo.toml b/oneshot-condvar/Cargo.toml new file mode 100644 index 000000000..dde2e3449 --- /dev/null +++ b/oneshot-condvar/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "defuse-oneshot-condvar" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[package.metadata.near.reproducible_build] +image = "sourcescan/cargo-near:0.16.2-rust-1.86.0" +image_digest = "sha256:74c24d4d912f893198b8b13e01d43e0f78f3b00b3df45bf555a707eb4918a54e" +passed_env = [] +container_build_command = [ + "cargo", + "near", + "build", + "non-reproducible-wasm", + "--locked", + "--features=contract", + "--no-embed-abi", +] + +[dependencies] +defuse-auth-call = { workspace = true, optional = true } +defuse-near-utils.workspace = true +defuse-nep245.workspace = true +impl-tools.workspace = true +near-sdk = { workspace = true, features = ["unstable", "deterministic-account-ids"] } +serde_with.workspace = true +thiserror.workspace = true + +[features] +contract = [] +abi = [] +auth-call = ["dep:defuse-auth-call"] diff --git a/oneshot-condvar/README.md b/oneshot-condvar/README.md new file mode 100644 index 000000000..c3039152c --- /dev/null +++ b/oneshot-condvar/README.md @@ -0,0 +1,130 @@ +# OneshotCondVar + +A oneshot condition variable smart contract for NEAR blockchain. + +## Concept + +This contract combines two Rust synchronization primitives: + +1. **Oneshot Channel** - Can only be used once; after signaling, the contract self-destructs +2. **Condition Variable** - One party waits (`cv_wait`) while another notifies (`cv_notify_one`) + +### Why "OneshotCondVar"? + +- **Oneshot**: The contract can only complete one notification cycle, then cleans itself up +- **CondVar**: Exposes `cv_wait()` and `cv_notify_one()` semantics similar to `std::sync::Condvar` + +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> Idle: new() + + Idle --> WaitingForNotification: cv_wait() + Idle --> Authorized: cv_notify_one() + + WaitingForNotification --> Authorized: cv_notify_one() + WaitingForNotification --> Idle: cv_wait_resume() timeout + + Authorized --> Done: cv_wait_resume() or cv_wait() + + Done --> [*]: cleanup +``` + +## States + +| State | Description | +|-------|-------------| +| `Idle` | Initial state. Ready for `cv_wait()` or `cv_notify_one()` | +| `WaitingForNotification` | `cv_wait()` called, yield promise active waiting for notification | +| `Authorized` | `cv_notify_one()` called, notification received | +| `Done` | Terminal state, triggers contract cleanup | + +## State Transitions + +| From State | Method | To State | Notes | +|------------|--------|----------|-------| +| `Idle` | `cv_wait()` | `WaitingForNotification` | Creates yield promise | +| `Idle` | `cv_notify_one()` | `Authorized` | No yield to resume | +| `WaitingForNotification` | `cv_notify_one()` | `Authorized` | Resumes yield (may fail if timed out) | +| `WaitingForNotification` | `cv_wait_resume()` timeout | `Idle` | Emits `Timeout` event, can retry | +| `Authorized` | `cv_wait()` | `Done` | Immediate success, no yield | +| `Authorized` | `cv_wait_resume()` | `Done` | Yield resumed or race condition | + +## Execution Paths + +### Path 1: cv_wait() then cv_notify() (Happy path) + +``` +Idle ──cv_wait()──► WaitingForNotification ──cv_notify_one()──► Authorized ──cv_wait_resume()──► Done +``` + +1. `cv_wait()`: Creates yield promise, state → `WaitingForNotification` +2. `cv_notify_one()`: Resumes yield, state → `Authorized` +3. `cv_wait_resume()` callback: State → `Done`, returns `true` + +### Path 2: cv_wait() Timeout + +``` +Idle ──cv_wait()──► WaitingForNotification ──timeout──► Idle (can retry) +``` + +1. `cv_wait()`: Creates yield promise, state → `WaitingForNotification` +2. Yield times out +3. `cv_wait_resume()` callback: State → `Idle`, emits `Timeout`, returns `false` + +### Path 3: cv_notify() then cv_wait() (Notify first) + +``` +Idle ──cv_notify_one()──► Authorized ──cv_wait()──► Done +``` + +1. `cv_notify_one()`: State → `Authorized` +2. `cv_wait()`: Immediate success, state → `Done`, returns `true` + +### Path 4: Race Condition (Timeout + Late Notification) + +``` +Idle ──cv_wait()──► WaitingForNotification ──(yield times out)──► cv_notify_one() ──► Authorized ──cv_wait_resume()──► Done +``` + +1. `cv_wait()`: State → `WaitingForNotification` +2. Yield times out internally (callback not yet executed) +3. `cv_notify_one()` arrives: `yield.resume()` fails, but state → `Authorized` +4. `cv_wait_resume()` callback: Sees `Authorized` → `Done`, returns `true` + +## Error Conditions + +| Current State | Method | Error | +|---------------|--------|-------| +| `WaitingForNotification` | `cv_wait()` | "already waiting for notification" | +| `Done` | `cv_wait()` | "already done" | +| `Authorized` | `cv_notify_one()` | "already notified" | +| `Done` | `cv_notify_one()` | "already notified" | + +## API + +### `cv_wait() -> PromiseOrValue` +Called by the `authorizee` to wait for notification. Returns: +- `true` if notification received (state becomes `Done`) +- `false` if timeout occurred (state resets to `Idle`, can retry) + +### `cv_notify_one()` +Called by the `notifier_id` to signal notification. Wakes up any waiting `cv_wait()`. + +### `cv_is_notified() -> bool` +Returns `true` if state is `Authorized` or `Done`. + +## Usage Pattern + +``` +Party A (authorizee) Contract Party B (notifier_id) + | | | + |------- cv_wait() ------>| | + | [WaitingForNotification] | + | |<--- cv_notify_one() ----| + | [Authorized] | + |<-- cv_wait_resume() ----| | + | returns true [Done] | + | [cleanup] | +``` diff --git a/oneshot-condvar/src/contract/auth_call.rs b/oneshot-condvar/src/contract/auth_call.rs new file mode 100644 index 000000000..459576781 --- /dev/null +++ b/oneshot-condvar/src/contract/auth_call.rs @@ -0,0 +1,27 @@ +use defuse_auth_call::AuthCallee; +use defuse_near_utils::UnwrapOrPanicError; +use near_sdk::{AccountId, PromiseOrValue, env, near, require}; + +use super::{Contract, ContractExt}; + +const ERR_MSG_NOT_EMPTY: &str = "message must be empty"; +const ERR_WRONG_AUTH_CALLER: &str = "Unauthorized auth contract"; +const ERR_WRONG_SIGNER: &str = "Unauthorized on_auth signer"; + +#[near] +impl AuthCallee for Contract { + #[payable] + fn on_auth(&mut self, signer_id: AccountId, msg: String) -> PromiseOrValue<()> { + require!(msg.is_empty(), ERR_MSG_NOT_EMPTY); + + let state = self.0.try_as_alive().unwrap_or_panic_display(); + require!( + env::predecessor_account_id() == state.state_init.auth_contract, + ERR_WRONG_AUTH_CALLER + ); + require!(signer_id == state.state_init.notifier_id, ERR_WRONG_SIGNER); + + self.do_notify(); + PromiseOrValue::Value(()) + } +} diff --git a/oneshot-condvar/src/contract/cleanup.rs b/oneshot-condvar/src/contract/cleanup.rs new file mode 100644 index 000000000..7381d1551 --- /dev/null +++ b/oneshot-condvar/src/contract/cleanup.rs @@ -0,0 +1,48 @@ +use near_sdk::{Promise, env}; + +use crate::{ContractStorage, Error, State, StateMachine, event::Event}; + +use super::Contract; + +impl Contract { + #[inline] + pub(super) fn cleanup_guard(&mut self) -> CleanupGuard<'_> { + CleanupGuard(&mut self.0) + } +} + +pub struct CleanupGuard<'a>(&'a mut ContractStorage); + +impl<'a> CleanupGuard<'a> { + #[inline] + pub const fn as_alive(&self) -> Option<&State> { + self.0.0.as_ref() + } + + #[inline] + pub const fn as_alive_mut(&mut self) -> Option<&mut State> { + self.0.0.as_mut() + } + + #[inline] + pub fn try_as_alive_mut(&mut self) -> Result<&mut State, Error> { + self.as_alive_mut().ok_or(Error::CleanupInProgress) + } + + #[must_use] + pub fn maybe_cleanup(&mut self) -> Option { + self.0 + .0 + .take_if(|s| matches!(s.state, StateMachine::Done)) + .map(|_state| { + Event::Cleanup.emit(); + Promise::new(env::current_account_id()).delete_account(env::signer_account_id()) + }) + } +} + +impl Drop for CleanupGuard<'_> { + fn drop(&mut self) { + self.maybe_cleanup().map(Promise::detach); + } +} diff --git a/oneshot-condvar/src/contract/mod.rs b/oneshot-condvar/src/contract/mod.rs new file mode 100644 index 000000000..bcf9d6193 --- /dev/null +++ b/oneshot-condvar/src/contract/mod.rs @@ -0,0 +1,152 @@ +#[cfg(feature = "auth-call")] +mod auth_call; +mod cleanup; + +use defuse_near_utils::UnwrapOrPanicError; +use near_sdk::{ + Gas, GasWeight, PanicOnDefault, Promise, PromiseError, PromiseOrValue, env, near, require, +}; + +use crate::{ + Error, OneshotCondVar, + event::Event, + storage::{ContractStorage, State, StateInit, StateMachine}, +}; + +const EMPTY_JSON: &[u8] = b"{}"; + +#[near(contract_state(key = ContractStorage::STATE_KEY))] +#[derive(Debug, PanicOnDefault)] +pub struct Contract(ContractStorage); + +impl ContractStorage { + #[inline] + const fn as_alive(&self) -> Option<&State> { + self.0.as_ref() + } + + #[inline] + fn try_as_alive(&self) -> Result<&State, Error> { + self.as_alive().ok_or(Error::CleanupInProgress) + } +} + +#[near] +impl Contract { + #[private] + #[allow(clippy::needless_pass_by_value)] + pub fn cv_wait_resume( + &mut self, + // #[callback_result] _resume_data: Result<(), PromiseError>, + ) -> PromiseOrValue { + let mut guard = self.cleanup_guard(); + let state = guard.try_as_alive_mut().unwrap_or_panic_display(); + + state.state = match state.state { + // The yield promise timed out while we were waiting for a notification. + // Reset to Idle state so the caller can retry cv_wait() if desired. + // This is the normal timeout path when no authorization arrives in time. + StateMachine::WaitingForNotification(_yield_id) => { + Event::Timeout.emit(); + StateMachine::Idle + } + // Authorization arrived before or (in corner case) after timeout + // in either case we want to transition from Authorized to Done + StateMachine::Authorized => StateMachine::Done, + // This callback is only scheduled by Promise::new_yield in cv_wait(), + // which transitions state to WaitingForNotification. We should never + // reach this callback while in Idle state. + StateMachine::Done | StateMachine::Idle => unreachable!(), + }; + + PromiseOrValue::Value(matches!(state.state, StateMachine::Done)) + } +} + +#[near] +impl Contract { + pub(crate) fn do_notify(&mut self) { + let mut guard = self.cleanup_guard(); + let state = guard.try_as_alive_mut().unwrap_or_panic_display(); + + state.state = match state.state { + StateMachine::Idle => StateMachine::Authorized, + StateMachine::WaitingForNotification(yield_id) => { + let _ = yield_id.resume(&[]); + // set state to authorized despite the status of yield resume. + // in both cases we want to keep the state machine in Authorized state + // - if yield succeeded - state will be changed to Done in callback + // - if yield failed (timeout) - state will be changed to done on next `cv_wait` + // call + StateMachine::Authorized + } + StateMachine::Done | StateMachine::Authorized => { + env::panic_str("already notified"); + } + }; + + Event::Authorized.emit(); + } +} + +#[near] +impl OneshotCondVar for Contract { + fn cv_view(&self) -> &State { + self.0.try_as_alive().unwrap_or_panic_display() + } + + fn cv_state(&self) -> &StateMachine { + &self.0.try_as_alive().unwrap_or_panic_display().state + } + + fn cv_is_notified(&self) -> bool { + self.0 + .as_alive() + .is_some_and(|s| matches!(s.state, StateMachine::Authorized | StateMachine::Done)) + } + + fn cv_wait(&mut self) -> PromiseOrValue { + let mut guard = self.cleanup_guard(); + let state = guard.try_as_alive_mut().unwrap_or_panic_display(); + + require!( + env::predecessor_account_id() == state.state_init.authorizee, + "Unauthorized authorizee" + ); + + match state.state { + StateMachine::Idle => { + let (promise, yield_id) = Promise::new_yield( + "cv_wait_resume", + EMPTY_JSON, + Gas::from_tgas(0), + GasWeight(1), + ); + state.state = StateMachine::WaitingForNotification(yield_id); + return promise.into(); + } + StateMachine::Authorized => { + state.state = StateMachine::Done; + } + StateMachine::WaitingForNotification(_) => { + env::panic_str("already waiting for notification"); + } + StateMachine::Done => { + env::panic_str("already done"); + } + } + + PromiseOrValue::Value(matches!(state.state, StateMachine::Done)) + } + + #[payable] + fn cv_notify_one(&mut self) { + let state = self.0.try_as_alive().unwrap_or_panic_display(); + require!( + env::predecessor_account_id() == state.state_init.notifier_id, + "Unauthorized signer" + ); + + self.do_notify(); + } +} diff --git a/oneshot-condvar/src/error.rs b/oneshot-condvar/src/error.rs new file mode 100644 index 000000000..ae71ae8e9 --- /dev/null +++ b/oneshot-condvar/src/error.rs @@ -0,0 +1,12 @@ +use near_sdk::FunctionError; + +use thiserror::Error as ThisError; + +#[derive(Debug, ThisError, FunctionError)] +pub enum Error { + #[error("borsh: {0}")] + Borsh(near_sdk::borsh::io::Error), + + #[error("cleanup in progress")] + CleanupInProgress, +} diff --git a/oneshot-condvar/src/event.rs b/oneshot-condvar/src/event.rs new file mode 100644 index 000000000..12375446d --- /dev/null +++ b/oneshot-condvar/src/event.rs @@ -0,0 +1,13 @@ +use near_sdk::near; + +#[must_use = "make sure to `.emit()` this event"] +#[near(event_json(standard = "oneshot_condvar"))] +#[derive(Debug, Clone)] +pub enum Event { + #[event_version("1.0.0")] + Authorized, + #[event_version("1.0.0")] + Timeout, + #[event_version("1.0.0")] + Cleanup, +} diff --git a/oneshot-condvar/src/lib.rs b/oneshot-condvar/src/lib.rs new file mode 100644 index 000000000..0faeb8b21 --- /dev/null +++ b/oneshot-condvar/src/lib.rs @@ -0,0 +1,46 @@ +use std::borrow::Cow; + +use near_sdk::{ + AccountIdRef, Gas, PromiseOrValue, borsh, env::keccak256, ext_contract, json_types::U128, near, +}; + +/// Gas consumed by `cv_wait` in worst case (wait first, notify later). +pub const WAIT_GAS: Gas = Gas::from_tgas(7); + +#[cfg(feature = "contract")] +mod contract; +mod error; +pub mod event; +pub mod storage; + +pub use error::Error; +pub use storage::{ContractStorage, State, StateInit, StateMachine}; + +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone)] +pub struct CondVarContext<'a> { + pub sender_id: Cow<'a, AccountIdRef>, + pub token_ids: Cow<'a, [defuse_nep245::TokenId]>, + pub amounts: Cow<'a, [U128]>, + pub salt: [u8; 32], + pub msg: Cow<'a, str>, +} + +impl CondVarContext<'_> { + pub fn hash(&self) -> [u8; 32] { + let serialized = borsh::to_vec(&self) + .unwrap_or_else(|_| unreachable!("CondVarContext is always serializable")); + keccak256(&serialized) + .try_into() + .unwrap_or_else(|_| unreachable!()) + } +} + +#[ext_contract(ext_oneshot_condvar)] +pub trait OneshotCondVar { + fn cv_state(&self) -> &StateMachine; + fn cv_view(&self) -> &State; + fn cv_is_notified(&self) -> bool; + fn cv_wait(&mut self) -> PromiseOrValue; + fn cv_notify_one(&mut self); +} diff --git a/oneshot-condvar/src/storage.rs b/oneshot-condvar/src/storage.rs new file mode 100644 index 000000000..9ad54a808 --- /dev/null +++ b/oneshot-condvar/src/storage.rs @@ -0,0 +1,71 @@ +use std::collections::BTreeMap; + +use crate::error::Error; +use near_sdk::{AccountId, GlobalContractId, YieldId, borsh, near}; +use serde_with::{hex::Hex, serde_as}; + +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone, PartialEq, Eq, Copy)] +pub enum StateMachine { + Idle, + WaitingForNotification(YieldId), + Authorized, + Done, +} + +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StateInit { + pub escrow_contract_id: GlobalContractId, + #[cfg(feature = "auth-call")] + pub auth_contract: AccountId, + pub notifier_id: AccountId, + pub authorizee: AccountId, + #[serde_as(as = "Hex")] + pub msg_hash: [u8; 32], +} + +/// The actual state data containing initialization params and state machine +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct State { + #[serde(flatten)] + pub state_init: StateInit, + pub state: StateMachine, +} + +impl State { + #[inline] + pub const fn new(state_init: StateInit) -> Self { + Self { + state_init, + state: StateMachine::Idle, + } + } +} + +/// Contract storage wrapper - None means cleanup is in progress +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractStorage( + /// If `None`, notification completed and contract is being deleted + pub(crate) Option, +); + +impl ContractStorage { + pub(crate) const STATE_KEY: &[u8] = b""; + + #[inline] + pub const fn init(state_init: StateInit) -> Self { + Self(Some(State::new(state_init))) + } + + pub fn init_state(state_init: StateInit) -> Result, Vec>, Error> { + let storage = Self::init(state_init); + Ok([( + Self::STATE_KEY.to_vec(), + borsh::to_vec(&storage).map_err(Error::Borsh)?, + )] + .into()) + } +} diff --git a/sandbox/Cargo.toml b/sandbox/Cargo.toml index 180bb0a05..12fd9a715 100644 --- a/sandbox/Cargo.toml +++ b/sandbox/Cargo.toml @@ -8,21 +8,33 @@ repository.workspace = true [lints] workspace = true +[features] +default = ["condvar", "escrow-proxy", "escrow-swap"] +condvar = ["dep:defuse-oneshot-condvar"] +escrow-proxy = ["dep:defuse-escrow-proxy"] +escrow-swap = ["escrow-proxy", "defuse-escrow-proxy/escrow-swap", "dep:defuse-escrow-swap"] + [dependencies] defuse-crypto = { workspace = true, features = ["near-api-types"] } +defuse-escrow-proxy = { workspace = true, optional = true } +defuse-escrow-swap = { workspace = true, optional = true } defuse-nep413 = { workspace = true, features = ["near-api"] } -defuse-randomness.workspace = true defuse-nep245.workspace = true +defuse-oneshot-condvar = { workspace = true, optional = true } +defuse-randomness.workspace = true tokio.workspace = true rstest.workspace = true anyhow.workspace = true futures.workspace = true +libc = "0.2" impl-tools.workspace = true near-api.workspace = true +near-openapi-client.workspace = true near-openapi-types.workspace = true near-sandbox.workspace = true -near-sdk = { workspace = true, features = ["unit-testing"] } +near-sdk = { workspace = true, features = ["unit-testing", "deterministic-account-ids"] } +serde_json.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/sandbox/src/extensions/condvar.rs b/sandbox/src/extensions/condvar.rs new file mode 100644 index 000000000..76c259cf4 --- /dev/null +++ b/sandbox/src/extensions/condvar.rs @@ -0,0 +1,78 @@ +use std::{fs, path::Path, sync::LazyLock}; + +use defuse_oneshot_condvar::storage::{ContractStorage, State as OneshotCondVarState}; +use near_sdk::{ + AccountId, GlobalContractId, NearToken, + state_init::{StateInit, StateInitV1}, +}; +use serde_json::json; + +use crate::{Account, SigningAccount, api::types::transaction::actions::GlobalContractDeployMode}; + +// Re-export StateInit type for convenience (used to deploy oneshot-condvar instances) +pub use defuse_oneshot_condvar::storage::StateInit as State; + +pub static ONESHOT_CONDVAR_WASM: LazyLock> = LazyLock::new(|| { + let filename = Path::new(env!("CARGO_MANIFEST_DIR")).join("../res/defuse_oneshot_condvar.wasm"); + fs::read(filename.clone()).unwrap_or_else(|_| panic!("file {filename:?} should exist")) +}); + +pub trait OneshotCondVarExt { + async fn deploy_oneshot_condvar(&self, name: impl AsRef) -> AccountId; + async fn deploy_oneshot_condvar_instance( + &self, + global_contract_id: AccountId, + state: State, + ) -> AccountId; + async fn get_oneshot_condvar_instance_state( + &self, + global_contract_id: AccountId, + ) -> anyhow::Result; +} + +impl OneshotCondVarExt for SigningAccount { + async fn deploy_oneshot_condvar(&self, name: impl AsRef) -> AccountId { + let account = self.sub_account(name).unwrap(); + + self.tx(account.id().clone()) + .create_account() + .transfer(NearToken::from_near(20)) + .deploy_global( + ONESHOT_CONDVAR_WASM.clone(), + GlobalContractDeployMode::AccountId, + ) + .await + .unwrap(); + + account.id().clone() + } + + async fn deploy_oneshot_condvar_instance( + &self, + global_contract_id: AccountId, + state: State, + ) -> AccountId { + let raw_state = ContractStorage::init_state(state.clone()).unwrap(); + let solver1_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(global_contract_id.clone()), + data: raw_state.clone(), + }); + + let account = solver1_state_init.derive_account_id(); + + self.tx(account.clone()) + .state_init(global_contract_id, raw_state) + .transfer(NearToken::from_yoctonear(1)) + .await + .unwrap(); + account + } + + async fn get_oneshot_condvar_instance_state( + &self, + global_contract_id: AccountId, + ) -> anyhow::Result { + let account = Account::new(global_contract_id, self.network_config().clone()); + account.call_view_function_json("view", json!({})).await + } +} diff --git a/sandbox/src/extensions/escrow_proxy.rs b/sandbox/src/extensions/escrow_proxy.rs new file mode 100644 index 000000000..dddd94c93 --- /dev/null +++ b/sandbox/src/extensions/escrow_proxy.rs @@ -0,0 +1,69 @@ +use std::{fs, path::Path, sync::LazyLock}; + +use defuse_escrow_proxy::ProxyConfig; +#[cfg(feature = "escrow-swap")] +use defuse_escrow_swap::Params as EscrowParams; +#[cfg(feature = "escrow-swap")] +use near_sdk::AccountId; +use near_sdk::{Gas, NearToken}; +use serde_json::json; + +use crate::{FnCallBuilder, SigningAccount}; + +pub static ESCROW_PROXY_WASM: LazyLock> = LazyLock::new(|| { + let filename = Path::new(env!("CARGO_MANIFEST_DIR")).join("../res/defuse_escrow_proxy.wasm"); + fs::read(filename.clone()).unwrap_or_else(|_| panic!("file {filename:?} should exist")) +}); + +pub trait EscrowProxyExt { + async fn deploy_escrow_proxy(&self, config: ProxyConfig) -> anyhow::Result<()>; + async fn get_escrow_proxy_config(&self) -> anyhow::Result; + /// Call `cancel_escrow` on proxy contract. Requires caller to be owner. + #[cfg(feature = "escrow-swap")] + async fn cancel_escrow( + &self, + proxy_contract: &AccountId, + params: &EscrowParams, + ) -> anyhow::Result<()>; +} + +impl EscrowProxyExt for SigningAccount { + async fn deploy_escrow_proxy(&self, config: ProxyConfig) -> anyhow::Result<()> { + self.tx(self.id().clone()) + .transfer(NearToken::from_near(5)) + .deploy(ESCROW_PROXY_WASM.clone()) + .function_call( + FnCallBuilder::new("new") + .json_args(json!({ + "config": config, + })) + .with_gas(Gas::from_tgas(50)), + ) + .await?; + + Ok(()) + } + + async fn get_escrow_proxy_config(&self) -> anyhow::Result { + self.call_view_function_json("config", json!({})).await + } + + #[cfg(feature = "escrow-swap")] + async fn cancel_escrow( + &self, + proxy_contract: &AccountId, + params: &EscrowParams, + ) -> anyhow::Result<()> { + self.tx(proxy_contract.clone()) + .function_call( + FnCallBuilder::new("cancel_escrow") + .json_args(json!({ + "params": params, + })) + .with_gas(Gas::from_tgas(100)) + .with_deposit(NearToken::from_yoctonear(1)), + ) + .await?; + Ok(()) + } +} diff --git a/sandbox/src/extensions/escrow_swap.rs b/sandbox/src/extensions/escrow_swap.rs new file mode 100644 index 000000000..5642f6136 --- /dev/null +++ b/sandbox/src/extensions/escrow_swap.rs @@ -0,0 +1,64 @@ +use std::{fs, path::Path, sync::LazyLock}; + +use near_sdk::{ + AccountId, GlobalContractId, NearToken, + state_init::{StateInit, StateInitV1}, +}; + +use crate::{SigningAccount, api::types::transaction::actions::GlobalContractDeployMode}; + +pub static ESCROW_SWAP_WASM: LazyLock> = LazyLock::new(|| { + let filename = Path::new(env!("CARGO_MANIFEST_DIR")).join("../res/defuse_escrow_swap.wasm"); + fs::read(filename.clone()).unwrap_or_else(|_| panic!("file {filename:?} should exists")) +}); + +pub trait EscrowSwapExt { + /// Deploy global escrow-swap contract (shared code) + async fn deploy_escrow_swap_global(&self, name: impl AsRef) -> AccountId; + + /// Deploy an escrow-swap instance with specific params using `state_init` + async fn deploy_escrow_swap_instance( + &self, + global_contract_id: AccountId, + params: &defuse_escrow_swap::Params, + ) -> AccountId; +} + +impl EscrowSwapExt for SigningAccount { + async fn deploy_escrow_swap_global(&self, name: impl AsRef) -> AccountId { + let account = self.sub_account(name).unwrap(); + + self.tx(account.id().clone()) + .create_account() + .transfer(NearToken::from_near(50)) + .deploy_global( + ESCROW_SWAP_WASM.clone(), + GlobalContractDeployMode::AccountId, + ) + .await + .unwrap(); + + account.id().clone() + } + + async fn deploy_escrow_swap_instance( + &self, + global_contract_id: AccountId, + params: &defuse_escrow_swap::Params, + ) -> AccountId { + let raw_state = defuse_escrow_swap::ContractStorage::init_state(params).unwrap(); + let state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(global_contract_id.clone()), + data: raw_state.clone(), + }); + let account_id = state_init.derive_account_id(); + + // Note: RPC may error but contract deploys successfully + let _ = self + .tx(account_id.clone()) + .state_init(global_contract_id, raw_state) + .transfer(NearToken::from_yoctonear(1)) + .await; + account_id + } +} diff --git a/sandbox/src/extensions/mod.rs b/sandbox/src/extensions/mod.rs index 4f68cd0cb..709bae7c7 100644 --- a/sandbox/src/extensions/mod.rs +++ b/sandbox/src/extensions/mod.rs @@ -1,8 +1,15 @@ #![allow(async_fn_in_trait)] pub mod acl; +#[cfg(feature = "condvar")] +pub mod condvar; +#[cfg(feature = "escrow-proxy")] +pub mod escrow_proxy; +#[cfg(feature = "escrow-swap")] +pub mod escrow_swap; pub mod ft; pub mod mt; +pub mod mt_receiver; pub mod nft; pub mod storage_management; pub mod wnear; diff --git a/sandbox/src/extensions/mt.rs b/sandbox/src/extensions/mt.rs index 605122693..6e7811c00 100644 --- a/sandbox/src/extensions/mt.rs +++ b/sandbox/src/extensions/mt.rs @@ -26,6 +26,16 @@ pub trait MtExt { msg: impl AsRef, ) -> anyhow::Result; + async fn mt_transfer_call_exec( + &self, + contract_id: impl Into, + receiver_id: impl AsRef, + token_id: impl AsRef, + amount: u128, + memo: impl Into>, + msg: impl AsRef, + ) -> anyhow::Result; + async fn mt_batch_transfer_call( &self, contract_id: impl Into, @@ -120,6 +130,31 @@ impl MtExt for SigningAccount { .map_err(Into::into) } + async fn mt_transfer_call_exec( + &self, + contract_id: impl Into, + receiver_id: impl AsRef, + token_id: impl AsRef, + amount: u128, + memo: impl Into>, + msg: impl AsRef, + ) -> anyhow::Result { + self.tx(contract_id) + .function_call( + FnCallBuilder::new("mt_transfer_call") + .json_args(json!({ + "receiver_id": receiver_id.as_ref(), + "token_id": token_id.as_ref(), + "amount": U128(amount), + "memo": memo.into(), + "msg": msg.as_ref(), + })) + .with_deposit(NearToken::from_yoctonear(1)), + ) + .exec_transaction() + .await + } + async fn mt_batch_transfer_call( &self, contract_id: impl Into, diff --git a/sandbox/src/extensions/mt_receiver.rs b/sandbox/src/extensions/mt_receiver.rs new file mode 100644 index 000000000..c78106d52 --- /dev/null +++ b/sandbox/src/extensions/mt_receiver.rs @@ -0,0 +1,81 @@ +use std::{collections::BTreeMap, fs, path::Path, sync::LazyLock}; + +use near_sdk::{ + AccountId, NearToken, + state_init::{StateInit, StateInitV1}, +}; + +use crate::{Account, SigningAccount, api::types::transaction::actions::GlobalContractDeployMode}; + +pub static MT_RECEIVER_STUB_WASM: LazyLock> = LazyLock::new(|| { + let filename = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../res/multi-token-receiver-stub/multi_token_receiver_stub.wasm"); + fs::read(filename.clone()).unwrap_or_else(|_| panic!("file {filename:?} should exists")) +}); + +pub trait MtReceiverStubExt { + /// Deploy as regular contract + async fn deploy_mt_receiver_stub(&self, name: impl AsRef) -> Account; + /// Deploy as global contract (code only) + async fn deploy_mt_receiver_stub_global(&self, name: impl AsRef) -> AccountId; + /// Deploy instance referencing global contract with arbitrary raw state + async fn deploy_mt_receiver_stub_instance( + &self, + global_contract_id: AccountId, + raw_state: BTreeMap, Vec>, + ) -> AccountId; +} + +impl MtReceiverStubExt for SigningAccount { + async fn deploy_mt_receiver_stub(&self, name: impl AsRef) -> Account { + let account = self.sub_account(name).unwrap(); + + self.tx(account.id().clone()) + .create_account() + .transfer(NearToken::from_near(20)) + .deploy(MT_RECEIVER_STUB_WASM.clone()) + .await + .unwrap(); + + account + } + + async fn deploy_mt_receiver_stub_global(&self, name: impl AsRef) -> AccountId { + let account = self.sub_account(name).unwrap(); + + self.tx(account.id().clone()) + .create_account() + .transfer(NearToken::from_near(100)) + .deploy_global( + MT_RECEIVER_STUB_WASM.clone(), + GlobalContractDeployMode::AccountId, + ) + .await + .unwrap(); + + account.id().clone() + } + + async fn deploy_mt_receiver_stub_instance( + &self, + global_contract_id: AccountId, + raw_state: BTreeMap, Vec>, + ) -> AccountId { + let state_init = StateInit::V1(StateInitV1 { + code: near_sdk::GlobalContractId::AccountId(global_contract_id.clone()), + data: raw_state.clone(), + }); + + let account = state_init.derive_account_id(); + + // NOTE: there is rpc error on state_init action but the contract itself is successfully + // deployed, so lets ignore error for now + let _ = self + .tx(account.clone()) + .state_init(global_contract_id, raw_state) + .transfer(NearToken::from_yoctonear(1)) + .await; + + account + } +} diff --git a/sandbox/src/lib.rs b/sandbox/src/lib.rs index 845c17bd3..85a42bdd3 100644 --- a/sandbox/src/lib.rs +++ b/sandbox/src/lib.rs @@ -4,12 +4,27 @@ pub mod helpers; pub mod tx; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; +use tokio::sync::OnceCell; pub use account::{Account, SigningAccount}; +#[cfg(feature = "condvar")] +pub use extensions::condvar::{ONESHOT_CONDVAR_WASM, OneshotCondVarExt, State as CondVarState}; +#[cfg(feature = "escrow-proxy")] +pub use extensions::escrow_proxy::{ESCROW_PROXY_WASM, EscrowProxyExt}; +#[cfg(feature = "escrow-swap")] +pub use extensions::escrow_swap::{ESCROW_SWAP_WASM, EscrowSwapExt}; +pub use extensions::{ + ft::{FtExt, FtViewExt}, + mt::{MtExt, MtViewExt}, + mt_receiver::{MT_RECEIVER_STUB_WASM, MtReceiverStubExt}, + storage_management::{StorageManagementExt, StorageViewExt}, + wnear::{WNearDeployerExt, WNearExt}, +}; pub use helpers::*; +pub use tx::{FnCallBuilder, TxBuilder}; pub use anyhow; use impl_tools::autoimpl; @@ -22,7 +37,6 @@ use near_api::{NetworkConfig, RPCEndpoint, Signer, signer::generate_secret_key}; use near_sandbox::{GenesisAccount, SandboxConfig}; use near_sdk::{AccountId, AccountIdRef, NearToken}; use rstest::fixture; -use tokio::sync::OnceCell; use tracing::instrument; #[autoimpl(Deref using self.root)] @@ -81,28 +95,54 @@ impl Sandbox { pub fn sandbox(&self) -> &near_sandbox::Sandbox { self.sandbox.as_ref() } + + pub async fn fast_forward(&self, blocks: u64) { + self.sandbox.fast_forward(blocks).await.unwrap(); + } +} + +/// Shared sandbox instance for test fixtures. +/// Using `OnceCell>>` allows async init and taking ownership in atexit. +static SHARED_SANDBOX: OnceCell>> = OnceCell::const_new(); + +extern "C" fn cleanup_sandbox() { + if let Some(mutex) = SHARED_SANDBOX.get() { + if let Ok(mut guard) = mutex.lock() { + drop(guard.take()); + } + } } #[fixture] #[instrument] pub async fn sandbox(#[default(NearToken::from_near(100_000))] amount: NearToken) -> Sandbox { const SHARED_ROOT: &AccountIdRef = AccountIdRef::new_or_panic("test"); - - static SHARED_SANDBOX: OnceCell = OnceCell::const_new(); static SUB_COUNTER: AtomicUsize = AtomicUsize::new(0); - let shared = SHARED_SANDBOX - .get_or_init(|| Sandbox::new(SHARED_ROOT)) + let mutex = SHARED_SANDBOX + .get_or_init(|| async { + unsafe { + libc::atexit(cleanup_sandbox); + } + Mutex::new(Some(Sandbox::new(SHARED_ROOT).await)) + }) .await; + let (sandbox_arc, root_account) = mutex + .lock() + .unwrap() + .as_ref() + .map(|shared| (shared.sandbox.clone(), shared.root.clone())) + .unwrap(); + Sandbox { - root: shared + root: root_account .generate_subaccount( SUB_COUNTER.fetch_add(1, Ordering::Relaxed).to_string(), amount, ) .await .unwrap(), - sandbox: shared.sandbox.clone(), + sandbox: sandbox_arc, } } diff --git a/sandbox/src/tx/gas.rs b/sandbox/src/tx/gas.rs new file mode 100644 index 000000000..8869b2888 --- /dev/null +++ b/sandbox/src/tx/gas.rs @@ -0,0 +1,104 @@ +use near_api::types::transaction::{ + actions::Action, + result::{ExecutionFinalResult, ExecutionResult}, +}; +use near_sdk::{AccountId, Gas}; + +/// Gas information for an execution outcome +#[derive(Debug, Clone)] +pub struct GasInfo { + pub executor_id: AccountId, + pub gas_burnt: Gas, + pub method_name: Option, +} + +/// Extension trait for querying gas usage from execution results +pub trait ExecutionResultExt { + /// Get gas info for all outcomes matching the given executor + fn gas_by_executor(&self, executor: &AccountId) -> Vec; + + /// Get gas info for transaction outcome matching receiver + method name. + /// Returns None if no match (only checks the transaction, not receipts + /// since method names are not available for receipt outcomes). + fn gas_by_method(&self, receiver: &AccountId, method: &str) -> Option; +} + +impl ExecutionResultExt for ExecutionFinalResult { + fn gas_by_executor(&self, executor: &AccountId) -> Vec { + self.outcomes() + .into_iter() + .filter(|o| &o.executor_id == executor) + .map(|o| GasInfo { + executor_id: o.executor_id.clone(), + gas_burnt: Gas::from_gas(o.gas_burnt.as_gas()), + method_name: None, + }) + .collect() + } + + fn gas_by_method(&self, receiver: &AccountId, method: &str) -> Option { + let tx = self.transaction(); + if tx.receiver_id() != receiver { + return None; + } + + let has_method = tx.actions().iter().any(|action| { + if let Action::FunctionCall(fn_call) = action { + fn_call.method_name == method + } else { + false + } + }); + + if has_method { + let outcome = self.outcome(); + Some(GasInfo { + executor_id: outcome.executor_id.clone(), + gas_burnt: Gas::from_gas(outcome.gas_burnt.as_gas()), + method_name: Some(method.to_string()), + }) + } else { + None + } + } +} + +impl ExecutionResultExt for ExecutionResult { + fn gas_by_executor(&self, executor: &AccountId) -> Vec { + self.outcomes() + .into_iter() + .filter(|o| &o.executor_id == executor) + .map(|o| GasInfo { + executor_id: o.executor_id.clone(), + gas_burnt: Gas::from_gas(o.gas_burnt.as_gas()), + method_name: None, + }) + .collect() + } + + fn gas_by_method(&self, receiver: &AccountId, method: &str) -> Option { + let tx = self.transaction(); + if tx.receiver_id() != receiver { + return None; + } + + let has_method = tx.actions().iter().any(|action| { + if let Action::FunctionCall(fn_call) = action { + fn_call.method_name == method + } else { + false + } + }); + + if has_method { + let outcome = self.outcome(); + Some(GasInfo { + executor_id: outcome.executor_id.clone(), + gas_burnt: Gas::from_gas(outcome.gas_burnt.as_gas()), + method_name: Some(method.to_string()), + }) + } else { + None + } + } +} diff --git a/sandbox/src/tx/mod.rs b/sandbox/src/tx/mod.rs index 367bc2aeb..9551f7d0a 100644 --- a/sandbox/src/tx/mod.rs +++ b/sandbox/src/tx/mod.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use futures::{FutureExt, future::BoxFuture}; use near_api::{ PublicKey, Transaction, @@ -6,8 +8,10 @@ use near_api::{ transaction::{ actions::{ AddKeyAction, CreateAccountAction, DeployContractAction, - DeployGlobalContractAction, FunctionCallAction, GlobalContractDeployMode, - GlobalContractIdentifier, TransferAction, UseGlobalContractAction, + DeployGlobalContractAction, DeterministicAccountStateInit, + DeterministicAccountStateInitV1, DeterministicStateInitAction, FunctionCallAction, + GlobalContractDeployMode, GlobalContractIdentifier, TransferAction, + UseGlobalContractAction, }, result::{ExecutionFinalResult, ExecutionSuccess}, }, @@ -16,9 +20,11 @@ use near_api::{ use near_sdk::{AccountId, NearToken}; mod fn_call; +pub mod gas; mod wrappers; pub use fn_call::FnCallBuilder; +pub use gas::{ExecutionResultExt, GasInfo}; use crate::SigningAccount; use wrappers::TxOutcome; @@ -79,6 +85,19 @@ impl TxBuilder { )) } + #[must_use] + pub fn state_init(self, global_contract: AccountId, state: BTreeMap, Vec>) -> Self { + self.add_action(Action::DeterministicStateInit(Box::new( + DeterministicStateInitAction { + state_init: DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 { + code: GlobalContractIdentifier::AccountId(global_contract), + data: state, + }), + deposit: NearToken::from_near(0), + }, + ))) + } + #[must_use] pub fn add_full_access_key(self, pk: impl Into) -> Self { self.add_key( diff --git a/sandbox/src/tx/wrappers.rs b/sandbox/src/tx/wrappers.rs index 7d003155d..dfdea5f92 100644 --- a/sandbox/src/tx/wrappers.rs +++ b/sandbox/src/tx/wrappers.rs @@ -1,5 +1,6 @@ -use near_api::types::transaction::result::{ - ExecutionFinalResult, ExecutionOutcome, ValueOrReceiptId, +use near_api::types::transaction::{ + actions::Action, + result::{ExecutionFinalResult, ExecutionOutcome, ValueOrReceiptId}, }; use std::fmt::Debug; @@ -10,10 +11,32 @@ impl Debug for TxOutcome<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "{} -> {}: ", + "{} -> {}", self.0.transaction().signer_id(), self.0.transaction().receiver_id() )?; + + // Extract method names from FunctionCall actions + let method_names: Vec<_> = self + .0 + .transaction() + .actions() + .iter() + .filter_map(|action| { + if let Action::FunctionCall(fn_call) = action { + Some(fn_call.method_name.as_str()) + } else { + None + } + }) + .collect(); + + if !method_names.is_empty() { + write!(f, "::{}", method_names.join(", "))?; + } + + write!(f, ": ")?; + let outcomes: Vec<_> = self .0 .outcomes() diff --git a/tests/Cargo.toml b/tests/Cargo.toml index db1f47ec3..08086b34e 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -13,11 +13,16 @@ defuse = { workspace = true, features = ["contract", "sandbox"] } defuse-escrow-swap = { workspace = true, features = ["auth_call"] } defuse-near-utils = { workspace = true } defuse-poa-factory = { workspace = true, features = ["contract", "sandbox"] } +defuse-core.workspace = true +defuse-escrow-proxy = { workspace = true, features = ["escrow-swap"] } +defuse-oneshot-condvar.workspace = true + + defuse-serde-utils = { workspace = true } defuse-randomness.workspace = true defuse-test-utils.workspace = true -defuse-sandbox.workspace = true - +defuse-sandbox = { workspace = true, features = ["escrow-swap"] } +defuse-token-id.workspace = true futures = "0.3" anyhow.workspace = true arbitrary.workspace = true @@ -30,7 +35,7 @@ impl-tools.workspace = true itertools.workspace = true multi-token-receiver-stub = { path = "contracts/multi-token-receiver-stub" } near-crypto.workspace = true -near-sdk = { workspace = true, features = ["unit-testing"] } +near-sdk = { workspace = true, features = ["unit-testing", "deterministic-account-ids"] } near-contract-standards.workspace = true rstest.workspace = true serde_json.workspace = true diff --git a/tests/contracts/multi-token-receiver-stub/Cargo.toml b/tests/contracts/multi-token-receiver-stub/Cargo.toml index b5c9434e7..6eaabe743 100644 --- a/tests/contracts/multi-token-receiver-stub/Cargo.toml +++ b/tests/contracts/multi-token-receiver-stub/Cargo.toml @@ -11,13 +11,16 @@ crate-type = ["cdylib", "rlib"] [dependencies] defuse = { workspace = true } defuse-nep245.workspace = true +near-contract-standards.workspace = true near-sdk.workspace = true [dev-dependencies] +defuse-sandbox.workspace = true near-sdk = { workspace = true, features = ["unit-testing"] } near-workspaces.workspace = true tokio.workspace = true serde_json.workspace = true +futures.workspace = true [features] abi = ["defuse/abi"] diff --git a/tests/contracts/multi-token-receiver-stub/src/lib.rs b/tests/contracts/multi-token-receiver-stub/src/lib.rs index 8b841dcfd..30e4470b5 100644 --- a/tests/contracts/multi-token-receiver-stub/src/lib.rs +++ b/tests/contracts/multi-token-receiver-stub/src/lib.rs @@ -1,6 +1,7 @@ use defuse::core::payload::multi::MultiPayload; use defuse::intents::ext_intents; use defuse_nep245::{TokenId, receiver::MultiTokenReceiver}; +use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{AccountId, PromiseOrValue, env, json_types::U128, near, serde_json}; /// Minimal stub contract used for integration tests. @@ -37,9 +38,8 @@ impl MultiTokenReceiver for Contract { near_sdk::env::log_str(&format!( "STUB::mt_on_transfer: sender_id={sender_id}, previous_owner_ids={previous_owner_ids:?}, token_ids={token_ids:?}, amounts={amounts:?}, msg={msg}" )); - let mode = serde_json::from_str(&msg).unwrap_or_default(); - match mode { + match serde_json::from_str(&msg).unwrap() { MTReceiverMode::ReturnValue(value) => PromiseOrValue::Value(vec![value; amounts.len()]), MTReceiverMode::ReturnValues(values) => PromiseOrValue::Value(values), MTReceiverMode::AcceptAll => PromiseOrValue::Value(vec![U128(0); amounts.len()]), @@ -57,6 +57,36 @@ impl MultiTokenReceiver for Contract { } } +/// FT receiver mode for testing +#[derive(Debug, Clone, Default)] +#[near(serializers = [json])] +pub enum FTReceiverMode { + #[default] + AcceptAll, + ReturnValue(U128), + Panic, +} + +#[near] +impl FungibleTokenReceiver for Contract { + fn ft_on_transfer( + &mut self, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + near_sdk::env::log_str(&format!( + "STUB::ft_on_transfer: sender_id={sender_id}, amount={amount:?}, msg={msg}" + )); + + match serde_json::from_str(&msg).unwrap_or_default() { + FTReceiverMode::AcceptAll => PromiseOrValue::Value(U128(0)), + FTReceiverMode::ReturnValue(value) => PromiseOrValue::Value(value), + FTReceiverMode::Panic => env::panic_str("FTReceiverMode::Panic"), + } + } +} + #[near] impl Contract { #[private] diff --git a/tests/contracts/multi-token-receiver-stub/tests/global_deployment.rs b/tests/contracts/multi-token-receiver-stub/tests/global_deployment.rs new file mode 100644 index 000000000..7ba81b98e --- /dev/null +++ b/tests/contracts/multi-token-receiver-stub/tests/global_deployment.rs @@ -0,0 +1,50 @@ +use std::collections::BTreeMap; + +use defuse_sandbox::{Account, MtReceiverStubExt, Sandbox}; +use near_sdk::{ + AccountId, + borsh::{self, BorshSerialize}, +}; + +/// Helper to serialize a struct into raw state format (`BTreeMap, Vec>`) +fn serialize_to_raw_state(value: &T) -> BTreeMap, Vec> { + let serialized = borsh::to_vec(value).expect("serialization should succeed"); + [(b"".to_vec(), serialized)].into() +} + +#[tokio::test] +async fn different_states_produce_different_addresses() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + + // Deploy global contract + let global_contract_id = root.deploy_mt_receiver_stub_global("mt-global").await; + + // Two different state structs - using simple tuples with different values + let state_a: (u64, String) = (42, "state_a".to_string()); + let state_b: (u64, String) = (99, "state_b".to_string()); + + let raw_state_a = serialize_to_raw_state(&state_a); + let raw_state_b = serialize_to_raw_state(&state_b); + + // Deploy both instances + let account_a = root + .deploy_mt_receiver_stub_instance(global_contract_id.clone(), raw_state_a) + .await; + let account_b = root + .deploy_mt_receiver_stub_instance(global_contract_id.clone(), raw_state_b) + .await; + + // Verify addresses are different + assert_ne!( + account_a, account_b, + "Different states should produce different addresses" + ); + + // Verify both accounts exist + let acc_a = Account::new(account_a.clone(), root.network_config().clone()); + let acc_b = Account::new(account_b.clone(), root.network_config().clone()); + + assert!(acc_a.view().await.is_ok(), "Account A should exist"); + assert!(acc_b.view().await.is_ok(), "Account B should exist"); +} diff --git a/tests/src/tests/defuse/env/builder.rs b/tests/src/tests/defuse/env/builder.rs index 49ca62acc..d3b726069 100644 --- a/tests/src/tests/defuse/env/builder.rs +++ b/tests/src/tests/defuse/env/builder.rs @@ -135,7 +135,7 @@ impl EnvBuilder { let defuse = self.deploy_defuse(root, &wnear, deploy_legacy).await; let env = Env { - defuse: defuse.into(), + defuse, wnear: wnear.into(), poa_factory: poa_factory.into(), sandbox, diff --git a/tests/src/tests/defuse/env/mod.rs b/tests/src/tests/defuse/env/mod.rs index 4c26c316e..6b834cfe3 100644 --- a/tests/src/tests/defuse/env/mod.rs +++ b/tests/src/tests/defuse/env/mod.rs @@ -22,6 +22,7 @@ use defuse_sandbox::extensions::storage_management::StorageManagementExt; use defuse_sandbox::tx::FnCallBuilder; use defuse_sandbox::{Account, Sandbox, SigningAccount, read_wasm}; use defuse_test_utils::random::{Seed, rng}; +use defuse_token_id::{TokenId, nep141::Nep141TokenId}; use futures::future::try_join_all; use impl_tools::autoimpl; use multi_token_receiver_stub::MTReceiverMode; @@ -43,7 +44,7 @@ pub struct Env { pub wnear: Account, - pub defuse: Account, + pub defuse: SigningAccount, pub poa_factory: Account, @@ -114,6 +115,73 @@ impl Env { self.create_named_token(name).await } + pub async fn create_ft_token_with_initial_balances( + &self, + balances: impl IntoIterator, + ) -> anyhow::Result<(Account, TokenId)> { + let account_id = generate_random_account_id(self.poa_factory.id()) + .expect("Failed to generate random account ID"); + let name = account_id + .as_str() + .trim_end_matches(&format!(".{}", self.poa_factory.id())); + + let token = self.create_named_token(name).await; + let balances = balances.into_iter().collect::>(); + + // First: storage deposits concurrently + self.ft_storage_deposit_for_accounts(token.id(), balances.iter().map(|(user, _)| user)) + .await?; + + // Then: token minting concurrently + let token_name = self.poa_ft_name(token.id()); + try_join_all( + balances + .iter() + .filter(|(_, amount)| *amount > 0) + .map(|(user, amount)| { + self.root().poa_factory_ft_deposit( + self.poa_factory.id(), + &token_name, + user, + *amount, + None, + None, + ) + }), + ) + .await?; + + let token_id = TokenId::from(Nep141TokenId::new(token.id().clone())); + Ok((token, token_id)) + } + + pub async fn create_mt_token_with_initial_balances( + &self, + balances: impl IntoIterator, + ) -> anyhow::Result<(Account, TokenId)> { + let token = self.create_token().await; + let balances = balances.into_iter().collect::>(); + + // Storage deposit only for defuse and root + self.ft_storage_deposit_for_accounts(token.id(), [self.defuse.id(), self.root().id()]) + .await?; + + // Mint tokens to root (so root can transfer to defuse) + self.ft_deposit_to_root(token.id()).await?; + + // Deposit to defuse for each user concurrently + try_join_all( + balances + .iter() + .filter(|(_, amount)| *amount > 0) + .map(|(user, amount)| self.defuse_ft_deposit_to(token.id(), *amount, user, None)), + ) + .await?; + + let token_id = TokenId::from(Nep141TokenId::new(token.id().clone())); + Ok((token, token_id)) + } + pub async fn create_named_user(&self, name: &str) -> SigningAccount { let account = self .generate_subaccount(name, INITIAL_USER_BALANCE) diff --git a/tests/src/tests/defuse/intents/relayers.rs b/tests/src/tests/defuse/intents/relayers.rs index c7de0d054..c0d3d6270 100644 --- a/tests/src/tests/defuse/intents/relayers.rs +++ b/tests/src/tests/defuse/intents/relayers.rs @@ -27,7 +27,7 @@ async fn relayer_keys() { .parse() .unwrap(); let new_relayer_signer = SigningAccount::new( - env.defuse.clone(), + env.defuse.clone().into(), Signer::from_secret_key(new_relayer_signer_secret_key).unwrap(), ); diff --git a/tests/src/tests/defuse/mod.rs b/tests/src/tests/defuse/mod.rs index 969acfa5d..8c20c9715 100644 --- a/tests/src/tests/defuse/mod.rs +++ b/tests/src/tests/defuse/mod.rs @@ -1,5 +1,5 @@ pub mod accounts; -mod env; +pub mod env; mod intents; mod state; mod storage; diff --git a/tests/src/tests/escrow/fees.rs b/tests/src/tests/escrow/fees.rs new file mode 100644 index 000000000..1587f253a --- /dev/null +++ b/tests/src/tests/escrow/fees.rs @@ -0,0 +1,449 @@ +//! Integration tests for escrow swap fees (protocol fees, integrator fees, surplus fees). + +use std::time::Duration; + +use crate::tests::defuse::env::Env; +use defuse_core::Deadline; +use defuse_escrow_swap::action::{FillMessageBuilder, FundMessageBuilder}; +use defuse_escrow_swap::decimal::UD128; +use defuse_escrow_swap::{ParamsBuilder, Pips, ProtocolFees}; +use defuse_sandbox::{EscrowSwapExt, MtExt, MtViewExt}; +use defuse_token_id::TokenId; +use defuse_token_id::nep245::Nep245TokenId; + +/// Test escrow with 1% protocol fee +#[tokio::test] +async fn test_escrow_with_protocol_fee() { + let env = Env::builder().build().await; + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + + let maker = env.create_named_user("maker").await; + let taker = env.create_named_user("taker").await; + let fee_collector = env.create_named_user("fee_collector").await; + + let amount = 1_000_u128; + + let ((_, src_token_id), (_, dst_token_id)) = futures::try_join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), amount)]), + env.create_mt_token_with_initial_balances([(taker.id().clone(), amount)]), + ) + .unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + src_token_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + dst_token_id.to_string(), + )); + + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([taker.id().clone()], dst_token), + ) + .with_protocol_fees(ProtocolFees { + fee: Pips::from_percent(1).unwrap(), // 1% fee + surplus: Pips::ZERO, + collector: fee_collector.id().clone(), + }) + .build(); + let fund_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + let fill_msg = FillMessageBuilder::new(escrow_params.clone()) + .with_deadline(Deadline::timeout(Duration::from_secs(120))) + .build(); + + let escrow_id = env + .root() + .deploy_escrow_swap_instance(escrow_swap_global, &escrow_params) + .await; + + // Fund escrow + maker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &src_token_id.to_string(), + amount, + None, + &serde_json::to_string(&fund_msg).unwrap(), + ) + .await + .unwrap(); + + // Fill escrow + taker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &dst_token_id.to_string(), + amount, + None, + &serde_json::to_string(&fill_msg).unwrap(), + ) + .await + .unwrap(); + + // Assert: taker gets all 1000 src + let taker_src = env + .defuse + .mt_balance_of(taker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!(taker_src, 1_000, "taker should receive all src"); + + // Assert: maker gets 990 dst (1000 - 1% fee) + let maker_dst = env + .defuse + .mt_balance_of(maker.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + maker_dst, 990, + "maker should receive 990 dst (1000 - 1% fee)" + ); + + // Assert: fee_collector gets 10 dst (1% of 1000) + let collector_dst = env + .defuse + .mt_balance_of(fee_collector.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + collector_dst, 10, + "fee_collector should receive 10 dst (1% fee)" + ); +} + +/// Test escrow with 2% integrator fee +#[tokio::test] +async fn test_escrow_with_integrator_fee() { + let env = Env::builder().build().await; + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + + let maker = env.create_named_user("maker").await; + let taker = env.create_named_user("taker").await; + let integrator = env.create_named_user("integrator").await; + + let amount = 1_000_u128; + + let ((_, src_token_id), (_, dst_token_id)) = futures::try_join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), amount)]), + env.create_mt_token_with_initial_balances([(taker.id().clone(), amount)]), + ) + .unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + src_token_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + dst_token_id.to_string(), + )); + + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([taker.id().clone()], dst_token), + ) + .with_integrator_fee(integrator.id().clone(), Pips::from_percent(2).unwrap()) // 2% fee + .build(); + let fund_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + let fill_msg = FillMessageBuilder::new(escrow_params.clone()) + .with_deadline(Deadline::timeout(Duration::from_secs(120))) + .build(); + + let escrow_id = env + .root() + .deploy_escrow_swap_instance(escrow_swap_global, &escrow_params) + .await; + + // Fund escrow + maker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &src_token_id.to_string(), + amount, + None, + &serde_json::to_string(&fund_msg).unwrap(), + ) + .await + .unwrap(); + + // Fill escrow + taker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &dst_token_id.to_string(), + amount, + None, + &serde_json::to_string(&fill_msg).unwrap(), + ) + .await + .unwrap(); + + // Assert: taker gets all 1000 src + let taker_src = env + .defuse + .mt_balance_of(taker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!(taker_src, 1_000, "taker should receive all src"); + + // Assert: maker gets 980 dst (1000 - 2% fee) + let maker_dst = env + .defuse + .mt_balance_of(maker.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + maker_dst, 980, + "maker should receive 980 dst (1000 - 2% fee)" + ); + + // Assert: integrator gets 20 dst (2% of 1000) + let integrator_dst = env + .defuse + .mt_balance_of(integrator.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + integrator_dst, 20, + "integrator should receive 20 dst (2% fee)" + ); +} + +/// Test escrow with combined protocol (1%) and integrator (2%) fees +#[tokio::test] +async fn test_escrow_with_combined_fees() { + let env = Env::builder().build().await; + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + + let maker = env.create_named_user("maker").await; + let taker = env.create_named_user("taker").await; + let protocol_collector = env.create_named_user("protocol").await; + let integrator = env.create_named_user("integrator").await; + + let amount = 1_000_u128; + + let ((_, src_token_id), (_, dst_token_id)) = futures::try_join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), amount)]), + env.create_mt_token_with_initial_balances([(taker.id().clone(), amount)]), + ) + .unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + src_token_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + dst_token_id.to_string(), + )); + + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([taker.id().clone()], dst_token), + ) + .with_protocol_fees(ProtocolFees { + fee: Pips::from_percent(1).unwrap(), // 1% protocol fee + surplus: Pips::ZERO, + collector: protocol_collector.id().clone(), + }) + .with_integrator_fee(integrator.id().clone(), Pips::from_percent(2).unwrap()) // 2% integrator fee + .build(); + let fund_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + let fill_msg = FillMessageBuilder::new(escrow_params.clone()) + .with_deadline(Deadline::timeout(Duration::from_secs(120))) + .build(); + + let escrow_id = env + .root() + .deploy_escrow_swap_instance(escrow_swap_global, &escrow_params) + .await; + + // Fund escrow + maker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &src_token_id.to_string(), + amount, + None, + &serde_json::to_string(&fund_msg).unwrap(), + ) + .await + .unwrap(); + + // Fill escrow + taker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &dst_token_id.to_string(), + amount, + None, + &serde_json::to_string(&fill_msg).unwrap(), + ) + .await + .unwrap(); + + // Assert: taker gets all 1000 src + let taker_src = env + .defuse + .mt_balance_of(taker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!(taker_src, 1_000, "taker should receive all src"); + + // Assert: maker gets 970 dst (1000 - 3% total fees) + let maker_dst = env + .defuse + .mt_balance_of(maker.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + maker_dst, 970, + "maker should receive 970 dst (1000 - 3% fees)" + ); + + // Assert: protocol_collector gets 10 dst (1% of 1000) + let protocol_dst = env + .defuse + .mt_balance_of(protocol_collector.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!(protocol_dst, 10, "protocol should receive 10 dst (1% fee)"); + + // Assert: integrator gets 20 dst (2% of 1000) + let integrator_dst = env + .defuse + .mt_balance_of(integrator.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + integrator_dst, 20, + "integrator should receive 20 dst (2% fee)" + ); +} + +/// Test escrow with surplus fee (captures value when taker pays above maker's price) +/// Maker: 1000 src at price 1.0 (minimum 1000 dst) +/// Taker: fills at price 2.0 (pays 2000 dst for 1000 src) +/// Surplus = 2000 - 1000 = 1000 dst, 50% fee = 500 dst +#[tokio::test] +async fn test_escrow_with_surplus_fee() { + use defuse_escrow_swap::OverrideSend; + use defuse_escrow_swap::action::{FillAction, TransferAction, TransferMessage}; + + let env = Env::builder().build().await; + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + + let maker = env.create_named_user("maker").await; + let taker = env.create_named_user("taker").await; + let fee_collector = env.create_named_user("fee_collector").await; + + let maker_src = 1_000_u128; + let taker_dst = 2_000_u128; // Taker pays at price 2.0 + + let ((_, src_token_id), (_, dst_token_id)) = futures::try_join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), maker_src)]), + env.create_mt_token_with_initial_balances([(taker.id().clone(), taker_dst)]), + ) + .unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + src_token_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + dst_token_id.to_string(), + )); + + // Maker price 1.0, surplus fee 50% (no base fee) + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([taker.id().clone()], dst_token), + ) + .with_protocol_fees(ProtocolFees { + fee: Pips::ZERO, // no base fee + surplus: Pips::from_percent(50).unwrap(), // 50% surplus fee + collector: fee_collector.id().clone(), + }) + .build(); + let fund_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + + // Custom fill message with price 2.0 (higher than maker's 1.0) + let fill_msg = TransferMessage { + params: escrow_params.clone(), + action: TransferAction::Fill(FillAction { + price: UD128::from(2), // Taker offers price 2.0 + deadline: Deadline::timeout(Duration::from_secs(120)), + receive_src_to: OverrideSend::default(), + }), + }; + + let escrow_id = env + .root() + .deploy_escrow_swap_instance(escrow_swap_global, &escrow_params) + .await; + + // Fund escrow + maker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &src_token_id.to_string(), + maker_src, + None, + &serde_json::to_string(&fund_msg).unwrap(), + ) + .await + .unwrap(); + + // Fill escrow at higher price + taker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &dst_token_id.to_string(), + taker_dst, + None, + &serde_json::to_string(&fill_msg).unwrap(), + ) + .await + .unwrap(); + + // Assert: taker gets all 1000 src + let taker_src_bal = env + .defuse + .mt_balance_of(taker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!(taker_src_bal, 1_000, "taker should receive 1000 src"); + + // Assert: maker gets 1500 dst (2000 - 500 surplus fee) + let maker_dst_bal = env + .defuse + .mt_balance_of(maker.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + maker_dst_bal, 1_500, + "maker should receive 1500 dst (2000 - 500 surplus fee)" + ); + + // Assert: fee_collector gets 500 dst (50% of 1000 surplus) + let collector_dst = env + .defuse + .mt_balance_of(fee_collector.id(), &dst_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + collector_dst, 500, + "fee_collector should receive 500 dst (50% of surplus)" + ); +} diff --git a/tests/src/tests/escrow/mod.rs b/tests/src/tests/escrow/mod.rs index 68d39a556..1b046e0ff 100644 --- a/tests/src/tests/escrow/mod.rs +++ b/tests/src/tests/escrow/mod.rs @@ -1,7 +1,9 @@ #![allow(async_fn_in_trait, dead_code)] mod env; +mod fees; mod partial_fills; +mod swaps; use std::sync::LazyLock; diff --git a/tests/src/tests/escrow/partial_fills.rs b/tests/src/tests/escrow/partial_fills.rs index c345ffc6a..d65aec9cf 100644 --- a/tests/src/tests/escrow/partial_fills.rs +++ b/tests/src/tests/escrow/partial_fills.rs @@ -268,3 +268,125 @@ async fn maybe_view_escrow(escrow: &Account) { serde_json::to_value(&s).unwrap() ); } + +/// Test partial fill with dust: verifies remaining funds returned to maker after timeout +#[tokio::test] +async fn test_partial_fill_funds_returned_after_timeout() { + use super::EscrowExt; + use crate::tests::defuse::env::Env as DefuseEnv; + use defuse_escrow_swap::ParamsBuilder; + use defuse_escrow_swap::action::{FillMessageBuilder, FundMessageBuilder}; + use defuse_escrow_swap::decimal::UD128; + use defuse_sandbox::{EscrowSwapExt, MtExt, MtViewExt}; + + let env = DefuseEnv::builder().build().await; + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + + let maker = env.create_named_user("maker").await; + let taker = env.create_named_user("taker").await; + + // Price 0.333333: taker pays 0.333333 dst per 1 src (gets ~3 src per 1 dst) + // Maker deposits 1000 src, taker fills with 166 dst (~50%) + // floor(166 / 0.333333) = 498 src to taker + // 1000 - 498 = 502 src remaining (partial + rounding dust) + let maker_balance = 1_000_u128; + let fill_amount = 166_u128; + let price: UD128 = "0.333333".parse().unwrap(); + let expected_taker_src = 498_u128; // floor(166 / 0.333333) + let expected_maker_refund = maker_balance - expected_taker_src; // 502 + + let ((_, src_token_id), (_, dst_token_id)) = futures::try_join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), maker_balance)]), + env.create_mt_token_with_initial_balances([(taker.id().clone(), fill_amount)]), + ) + .unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + src_token_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + dst_token_id.to_string(), + )); + + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([taker.id().clone()], dst_token), + ) + .with_price(price) + .with_partial_fills_allowed(true) + .with_deadline(Deadline::timeout(Duration::from_secs(6))) + .build(); + let fund_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + let fill_msg = FillMessageBuilder::new(escrow_params.clone()) + .with_deadline(Deadline::timeout(Duration::from_secs(5))) + .build(); + + let escrow_id = env + .root() + .deploy_escrow_swap_instance(escrow_swap_global, &escrow_params) + .await; + + // Fund escrow + maker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &src_token_id.to_string(), + maker_balance, + None, + &serde_json::to_string(&fund_msg).unwrap(), + ) + .await + .unwrap(); + + // Partial fill (~50%) + taker + .mt_transfer_call( + env.defuse.id(), + &escrow_id, + &dst_token_id.to_string(), + fill_amount, + None, + &serde_json::to_string(&fill_msg).unwrap(), + ) + .await + .unwrap(); + + // Verify taker received expected src + let taker_src = env + .defuse + .mt_balance_of(taker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + taker_src, expected_taker_src, + "taker should have floor(166/0.333333) = 498" + ); + + // Maker has 0 before close (remaining in escrow) + let maker_src_before = env + .defuse + .mt_balance_of(maker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!(maker_src_before, 0, "maker src should be 0 before close"); + + // Wait for deadline to expire + tokio::time::sleep(Duration::from_secs(7)).await; + + // Close escrow - remaining funds return to maker + maker.es_close(&escrow_id, &escrow_params).await.unwrap(); + + // Verify maker received remaining src (partial fill remainder + rounding dust) + let maker_src_after = env + .defuse + .mt_balance_of(maker.id(), &src_token_id.to_string()) + .await + .unwrap(); + assert_eq!( + maker_src_after, expected_maker_refund, + "maker should receive 502 src after close" + ); +} diff --git a/tests/src/tests/escrow/swaps.rs b/tests/src/tests/escrow/swaps.rs new file mode 100644 index 000000000..9223e3dd1 --- /dev/null +++ b/tests/src/tests/escrow/swaps.rs @@ -0,0 +1,362 @@ +//! Parameterized integration tests for escrow swap direct fills. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::time::Duration; + +use crate::tests::defuse::env::Env; +use defuse_core::Deadline; +use defuse_escrow_swap::action::{FillMessageBuilder, FundMessageBuilder}; +use defuse_escrow_swap::decimal::UD128; +use defuse_escrow_swap::{OverrideSend, ParamsBuilder}; +use defuse_sandbox::{EscrowSwapExt, MtExt, MtViewExt}; +use defuse_token_id::TokenId; +use defuse_token_id::nep245::Nep245TokenId; +use rstest::rstest; + +// User name constants for parameterized tests +const MAKER: &str = "alice"; +const BOB: &str = "bob"; +const CHARLIE: &str = "charlie"; +const DAVE: &str = "dave"; + +/// Test case configuration for escrow swap +#[derive(Debug, Clone)] +struct EscrowSwapTestCase { + price: UD128, + /// Maker (ALICE) initial `src_token` balance + maker_balance: u128, + /// Fills: `(account_name, fill_amount)` - `dst_token` minted per account + fills: Vec<(&'static str, u128)>, + /// Expected `src_token` balances after fills (dust may remain in escrow) + expected_src_balances: Vec<(&'static str, u128)>, + /// Expected `dst_token` balances after fills + expected_dst_balances: Vec<(&'static str, u128)>, + /// Override where maker receives dst tokens (defaults to maker) + maker_receive_dst_to: Option<&'static str>, + /// Override where taker receives src tokens (defaults to taker) + taker_receive_src_to: Option<&'static str>, +} + +impl Default for EscrowSwapTestCase { + fn default() -> Self { + Self { + price: UD128::ONE, + maker_balance: 0, + fills: vec![], + expected_src_balances: vec![], + expected_dst_balances: vec![], + maker_receive_dst_to: None, + taker_receive_src_to: None, + } + } +} + +#[rstest] +#[case::simple_1_to_1_swap(EscrowSwapTestCase { + price: UD128::ONE, + maker_balance: 100_000_000, + fills: vec![(BOB, 100_000_000)], + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 100_000_000), + ], + expected_dst_balances: vec![ + (MAKER, 100_000_000), + (BOB, 0), + ], + ..Default::default() +})] +#[case::price_ratio_1_to_2(EscrowSwapTestCase { + price: UD128::from(2), + maker_balance: 1_000, + fills: vec![(BOB, 2_000)], + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 1_000), + ], + expected_dst_balances: vec![ + (MAKER, 2_000), + (BOB, 0), + ], + ..Default::default() +})] +#[case::price_ratio_1_to_3(EscrowSwapTestCase { + price: UD128::from(3), + maker_balance: 1_000, + fills: vec![(BOB, 3_000)], // 3000/3 = 1000 (exact) + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 1_000), + ], + expected_dst_balances: vec![ + (MAKER, 3_000), + (BOB, 0), + ], + ..Default::default() +})] +#[case::fractional_price_1_333333(EscrowSwapTestCase { + price: "1.333333".parse().unwrap(), // ≈4/3 + maker_balance: 1_000, + fills: vec![(BOB, 1_334)], // 1334/1.333333 = 1000.5... rounds to 1000 + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 1_000), + ], + expected_dst_balances: vec![ + (MAKER, 1_334), + (BOB, 0), + ], + ..Default::default() +})] +#[case::multiple_takers(EscrowSwapTestCase { + price: UD128::ONE, + maker_balance: 1_000, + fills: vec![ + (BOB, 400), // first taker fills 400 + (CHARLIE, 350), // second taker fills 350 + (DAVE, 250), // third taker fills remaining 250 + ], + // Each taker receives src tokens proportional to their fill + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 400), + (CHARLIE, 350), + (DAVE, 250), + ], + expected_dst_balances: vec![ + (MAKER, 1_000), // 400 + 350 + 250 + (BOB, 0), + (CHARLIE, 0), + (DAVE, 0), + ], + ..Default::default() +})] +#[case::overfunding_excess_refunded(EscrowSwapTestCase { + price: UD128::ONE, + maker_balance: 1_000, + fills: vec![(BOB, 1_500)], // taker sends 1500 but only 1000 needed + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 1_000), // receives all src tokens + ], + expected_dst_balances: vec![ + (MAKER, 1_000), // receives exactly what was needed + (BOB, 500), // excess 500 refunded + ], + ..Default::default() +})] +#[case::maker_dst_redirect(EscrowSwapTestCase { + price: UD128::ONE, + maker_balance: 1_000, + fills: vec![(BOB, 1_000)], + maker_receive_dst_to: Some(CHARLIE), // Maker's dst goes to Charlie + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 1_000), + (CHARLIE, 0), + ], + expected_dst_balances: vec![ + (MAKER, 0), // Maker gets nothing (redirected) + (BOB, 0), + (CHARLIE, 1_000), // Charlie receives maker's dst + ], + ..Default::default() +})] +#[case::taker_src_redirect(EscrowSwapTestCase { + price: UD128::ONE, + maker_balance: 1_000, + fills: vec![(BOB, 1_000)], + taker_receive_src_to: Some(DAVE), // Bob's src goes to Dave + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 0), // Bob gets nothing (redirected) + (DAVE, 1_000), // Dave receives Bob's src + ], + expected_dst_balances: vec![ + (MAKER, 1_000), + (BOB, 0), + (DAVE, 0), + ], + ..Default::default() +})] +#[case::both_redirects(EscrowSwapTestCase { + price: UD128::ONE, + maker_balance: 1_000, + fills: vec![(BOB, 1_000)], + maker_receive_dst_to: Some(CHARLIE), + taker_receive_src_to: Some(DAVE), + expected_src_balances: vec![ + (MAKER, 0), + (BOB, 0), + (CHARLIE, 0), + (DAVE, 1_000), // Dave gets src (redirected from Bob) + ], + expected_dst_balances: vec![ + (MAKER, 0), + (BOB, 0), + (CHARLIE, 1_000), // Charlie gets dst (redirected from Maker) + (DAVE, 0), + ], +})] +#[tokio::test] +async fn test_escrow_swap_direct_fill(#[case] test_case: EscrowSwapTestCase) { + use futures::FutureExt; + + // Arrange + let env = Env::builder().build().await; + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + let maker = env.create_named_user(MAKER).await; + + // Collect all unique accounts: takers + redirect destinations + let mut all_accounts: HashSet<&'static str> = + test_case.fills.iter().map(|(name, _)| *name).collect(); + if let Some(dst_redirect) = test_case.maker_receive_dst_to { + all_accounts.insert(dst_redirect); + } + if let Some(src_redirect) = test_case.taker_receive_src_to { + all_accounts.insert(src_redirect); + } + + let accounts: HashMap<_, _> = futures::future::join_all( + all_accounts + .iter() + .map(|name| env.create_named_user(name).map(|acc| (*name, acc))), + ) + .await + .into_iter() + .chain(std::iter::once((MAKER, maker.clone()))) + .collect(); + + // Takers for whitelist (only accounts that will fill) + let unique_takers: HashSet<_> = test_case.fills.iter().map(|(name, _)| *name).collect(); + let dst_token_balances = + test_case + .fills + .iter() + .fold(HashMap::new(), |mut acc, (name, amount)| { + *acc.entry(accounts.get(name).unwrap().id().clone()) + .or_default() += amount; + acc + }); + let ((_, src_token_defuse_id), (_, dst_token_defuse_id)) = futures::try_join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), test_case.maker_balance)]), + env.create_mt_token_with_initial_balances(dst_token_balances), + ) + .unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + src_token_defuse_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + dst_token_defuse_id.to_string(), + )); + + let mut params_builder = ParamsBuilder::new( + (maker.id().clone(), src_token), + ( + unique_takers + .iter() + .map(|name| accounts.get(name).unwrap().id().clone()), + dst_token, + ), + ) + .with_price(test_case.price) + .with_partial_fills_allowed(test_case.fills.len() > 1); + + // Apply maker receive_dst_to override if specified + if let Some(dst_redirect) = test_case.maker_receive_dst_to { + params_builder = params_builder.with_receive_dst_to(OverrideSend { + receiver_id: Some(accounts.get(dst_redirect).unwrap().id().clone()), + ..Default::default() + }); + } + + let escrow_params = params_builder.build(); + let fund_escrow_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + + let mut fill_builder = FillMessageBuilder::new(escrow_params.clone()) + .with_deadline(Deadline::timeout(Duration::from_secs(120))); + + // Apply taker receive_src_to override if specified + if let Some(src_redirect) = test_case.taker_receive_src_to { + fill_builder = fill_builder.with_receive_src_to(OverrideSend { + receiver_id: Some(accounts.get(src_redirect).unwrap().id().clone()), + ..Default::default() + }); + } + + let fill_escrow_msg = fill_builder.build(); + + // Act + let escrow_instance_id = env + .root() + .deploy_escrow_swap_instance(escrow_swap_global.clone(), &escrow_params) + .await; + + maker + .mt_transfer_call( + env.defuse.id(), + &escrow_instance_id, + &src_token_defuse_id.to_string(), + test_case.maker_balance, + None, + &serde_json::to_string(&fund_escrow_msg).unwrap(), + ) + .await + .unwrap(); + + for (taker_name, fill_amount) in &test_case.fills { + let taker = accounts.get(taker_name).unwrap(); + taker + .mt_transfer_call( + env.defuse.id(), + &escrow_instance_id, + &dst_token_defuse_id.to_string(), + *fill_amount, + None, + &serde_json::to_string(&fill_escrow_msg).unwrap(), + ) + .await + .unwrap(); + } + + // Assert balances after fills + let expected_src: BTreeMap<_, _> = test_case.expected_src_balances.iter().copied().collect(); + let expected_dst: BTreeMap<_, _> = test_case.expected_dst_balances.iter().copied().collect(); + + let src_token_id_str = src_token_defuse_id.to_string(); + let dst_token_id_str = dst_token_defuse_id.to_string(); + + let actual_src_balances: BTreeMap<_, _> = + futures::future::join_all(test_case.expected_src_balances.iter().map(|(name, _)| { + let acc = accounts.get(name).unwrap(); + env.defuse + .mt_balance_of(acc.id(), &src_token_id_str) + .map(|balance| (*name, balance.unwrap())) + })) + .await + .into_iter() + .collect(); + + let actual_dst_balances: BTreeMap<_, _> = + futures::future::join_all(test_case.expected_dst_balances.iter().map(|(name, _)| { + let acc = accounts.get(name).unwrap(); + env.defuse + .mt_balance_of(acc.id(), &dst_token_id_str) + .map(|balance| (*name, balance.unwrap())) + })) + .await + .into_iter() + .collect(); + + assert_eq!( + actual_src_balances, expected_src, + "src_token balances mismatch" + ); + assert_eq!( + actual_dst_balances, expected_dst, + "dst_token balances mismatch" + ); +} diff --git a/tests/src/tests/escrow_proxy/config.rs b/tests/src/tests/escrow_proxy/config.rs new file mode 100644 index 000000000..42ce26b74 --- /dev/null +++ b/tests/src/tests/escrow_proxy/config.rs @@ -0,0 +1,72 @@ +use defuse_escrow_proxy::ProxyConfig; +use defuse_sandbox::{EscrowProxyExt, Sandbox}; +use near_sdk::{AccountId, GlobalContractId, NearToken}; + +const INIT_BALANCE: NearToken = NearToken::from_near(100); + +#[tokio::test] +async fn escrow_proxy_deployment_and_config() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + + // Get the proxy account ID (will be created during deployment) + let proxy = root + .generate_subaccount("proxy", INIT_BALANCE) + .await + .unwrap(); + + let config = ProxyConfig { + owner: proxy.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId( + root.sub_account("per_fill_contract_id") + .unwrap() + .id() + .clone(), + ), + escrow_swap_contract_id: GlobalContractId::AccountId( + root.sub_account("escrow_swap_contract_id") + .unwrap() + .id() + .clone(), + ), + auth_contract: root.sub_account("auth_contract").unwrap().id().clone(), + auth_collee: root.sub_account("auth_collee").unwrap().id().clone(), + }; + + proxy.deploy_escrow_proxy(config.clone()).await.unwrap(); + let actual_config = proxy.get_escrow_proxy_config().await.unwrap(); + + assert_eq!(actual_config, config, "config should match"); +} + +#[tokio::test] +async fn owner_configuration() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + + let (owner, proxy_account) = futures::try_join!( + root.generate_subaccount("owner", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let config = ProxyConfig { + owner: owner.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId( + root.sub_account("per_fill_contract_id") + .unwrap() + .id() + .clone(), + ), + escrow_swap_contract_id: GlobalContractId::AccountId( + root.sub_account("escrow_swap_contract_id") + .unwrap() + .id() + .clone(), + ), + auth_contract: root.sub_account("auth_contract").unwrap().id().clone(), + auth_collee: root.sub_account("auth_collee").unwrap().id().clone(), + }; + + proxy_account.deploy_escrow_proxy(config).await.unwrap(); +} diff --git a/tests/src/tests/escrow_proxy/mod.rs b/tests/src/tests/escrow_proxy/mod.rs new file mode 100644 index 000000000..2dec57f8f --- /dev/null +++ b/tests/src/tests/escrow_proxy/mod.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod proxy_oneshot_condvar; diff --git a/tests/src/tests/escrow_proxy/proxy_oneshot_condvar.rs b/tests/src/tests/escrow_proxy/proxy_oneshot_condvar.rs new file mode 100644 index 000000000..911a50500 --- /dev/null +++ b/tests/src/tests/escrow_proxy/proxy_oneshot_condvar.rs @@ -0,0 +1,420 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; + +use crate::tests::defuse::env::Env; +use defuse_escrow_proxy::{ProxyConfig, TransferMessage}; +use defuse_oneshot_condvar::CondVarContext; +use defuse_oneshot_condvar::storage::{ContractStorage, StateInit as CondVarStateInit}; +use defuse_sandbox::extensions::storage_management::StorageManagementExt; +use defuse_sandbox::{ + EscrowProxyExt, FnCallBuilder, FtExt, FtViewExt, MtExt, MtReceiverStubExt, MtViewExt, + OneshotCondVarExt, +}; +use multi_token_receiver_stub::{FTReceiverMode, MTReceiverMode}; +use near_sdk::AccountId; +use near_sdk::{ + Gas, GlobalContractId, NearToken, + json_types::U128, + state_init::{StateInit, StateInitV1}, +}; + +/// Derive the oneshot-condvar instance account ID from its state +pub fn derive_oneshot_condvar_account_id( + global_contract_id: &GlobalContractId, + state: &CondVarStateInit, +) -> AccountId { + let raw_state = ContractStorage::init_state(state.clone()).unwrap(); + let state_init = StateInit::V1(StateInitV1 { + code: global_contract_id.clone(), + data: raw_state, + }); + state_init.derive_account_id() +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn test_proxy_returns_funds_on_timeout_of_authorization() { + let env = Env::builder().build().await; + let (condvar_global, mt_receiver_global) = futures::join!( + env.root().deploy_oneshot_condvar("global_transfer_auth"), + env.root() + .deploy_mt_receiver_stub_global("mt_receiver_global"), + ); + let mt_receiver_instance = env + .root() + .deploy_mt_receiver_stub_instance(mt_receiver_global.clone(), BTreeMap::default()) + .await; + + let (solver, relay, proxy) = futures::join!( + env.create_named_user("solver"), + env.create_named_user("relay"), + env.create_named_user("proxy"), + ); + + // Setup proxy + let config = ProxyConfig { + owner: proxy.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId(condvar_global.clone()), + escrow_swap_contract_id: GlobalContractId::AccountId(mt_receiver_global.clone()), + auth_contract: env.defuse.id().clone(), + auth_collee: relay.id().clone(), + }; + + proxy.deploy_escrow_proxy(config).await.unwrap(); + + // Create MT token with initial balance for solver + let initial_amount = 1_000_000u128; + let (_, token_id) = env + .create_mt_token_with_initial_balances([(solver.id().clone(), initial_amount)]) + .await + .unwrap(); + + let transfer_msg = TransferMessage { + receiver_id: mt_receiver_instance.clone(), + salt: [1u8; 32], + msg: String::new(), + }; + let msg_json = serde_json::to_string(&transfer_msg).unwrap(); + let token_id_str = token_id.to_string(); + let (transfer_result, ()) = futures::join!( + solver.mt_transfer_call( + env.defuse.id(), + proxy.id(), + &token_id_str, + initial_amount / 2, + None, + &msg_json, + ), + env.sandbox().fast_forward(250) + ); + + assert_eq!( + transfer_result.unwrap(), + 0, + "Used amount should be 0 when transfer times out and refunds" + ); + + assert_eq!( + initial_amount, + env.defuse + .mt_balance_of(solver.id(), &token_id_str) + .await + .unwrap(), + "Solver balance should be unchanged after timeout refund" + ); +} + +/// Test that transfer succeeds when relay authorizes via on_auth call +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn test_transfer_authorized_by_relay() { + let env = Env::builder().build().await; + + // Deploy global contracts in parallel + let (condvar_global, mt_receiver_global) = futures::join!( + env.root().deploy_oneshot_condvar("global_transfer_auth"), + env.root() + .deploy_mt_receiver_stub_global("mt_receiver_global"), + ); + + // Create accounts in parallel + let (solver, relay, proxy) = futures::join!( + env.create_named_user("solver"), + env.create_named_user("relay"), + env.create_named_user("proxy"), + ); + + // Use root as auth_contract since we need signing capability for on_auth call + let config = ProxyConfig { + owner: proxy.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId(condvar_global.clone()), + escrow_swap_contract_id: GlobalContractId::AccountId(mt_receiver_global.clone()), + auth_contract: env.root().id().clone(), + auth_collee: relay.id().clone(), + }; + + proxy.deploy_escrow_proxy(config.clone()).await.unwrap(); + + // Derive and pre-deploy the escrow instance (mt-receiver-stub) + let escrow_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(mt_receiver_global.clone()), + data: BTreeMap::new(), + }); + let escrow_instance_id = escrow_state_init.derive_account_id(); + + // Deploy escrow instance via state_init + env.root() + .tx(escrow_instance_id.clone()) + .state_init(mt_receiver_global.clone(), BTreeMap::new()) + .transfer(NearToken::from_yoctonear(1)) + .await + .unwrap(); + + // Create token with initial balance for solver + let initial_balance: u128 = 1_000_000; + let (_, token_id) = env + .create_mt_token_with_initial_balances([(solver.id().clone(), initial_balance)]) + .await + .unwrap(); + + // Record initial solver balance + let initial_solver_balance = env + .defuse + .mt_balance_of(solver.id(), &token_id.to_string()) + .await + .unwrap(); + + let inner_msg_json = serde_json::to_string(&MTReceiverMode::AcceptAll).unwrap(); + + // Build TransferMessage for the proxy (wraps inner message) + let transfer_msg = TransferMessage { + receiver_id: escrow_instance_id.clone(), + salt: [2u8; 32], // Different salt from timeout test + msg: inner_msg_json, + }; + let msg_json = serde_json::to_string(&transfer_msg).unwrap(); + + let proxy_transfer_amount: u128 = 100_000; + + // Derive the transfer-auth instance address (same logic as proxy uses) + // The hash is computed from CondVarContext, not the message itself + let context_hash = CondVarContext { + sender_id: Cow::Borrowed(solver.id()), + token_ids: Cow::Owned(vec![token_id.to_string()]), + amounts: Cow::Owned(vec![U128(proxy_transfer_amount)]), + salt: transfer_msg.salt, + msg: Cow::Borrowed(&msg_json), + } + .hash(); + + let auth_state = CondVarStateInit { + escrow_contract_id: config.escrow_swap_contract_id.clone(), + auth_contract: config.auth_contract.clone(), + notifier_id: config.auth_collee.clone(), + authorizee: proxy.id().clone(), + msg_hash: context_hash, + }; + let condvar_instance_id = derive_oneshot_condvar_account_id( + &GlobalContractId::AccountId(condvar_global.clone()), + &auth_state, + ); + + let token_id_str = token_id.to_string(); + + // Build raw state for state_init (same as proxy does) + let raw_state = ContractStorage::init_state(auth_state).unwrap(); + + // Run transfer and on_auth call concurrently + // Transfer starts the yield promise, on_auth authorizes it + let (_transfer_result, _auth_result) = futures::join!( + solver.mt_transfer_call( + env.defuse.id(), + proxy.id(), + &token_id_str, + proxy_transfer_amount, + None, + &msg_json, + ), + // Call on_auth from root (auth_contract) with relay as signer_id + // Include state_init to deploy the transfer-auth instance if not already deployed + async { + env.root() + .tx(condvar_instance_id.clone()) + .state_init(condvar_global.clone(), raw_state) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(serde_json::json!({ + "signer_id": relay.id(), + "msg": "", + })) + .with_gas(Gas::from_tgas(50)) + .with_deposit(NearToken::from_yoctonear(1)), + ) + .await + .unwrap(); + } + ); + + // Verify solver balance decreased + let final_solver_balance = env + .defuse + .mt_balance_of(solver.id(), &token_id_str) + .await + .unwrap(); + + assert_eq!( + initial_solver_balance - proxy_transfer_amount, + final_solver_balance, + "Solver balance should decrease by transferred amount" + ); + + // Verify escrow instance received the tokens + let escrow_balance = env + .defuse + .mt_balance_of(&escrow_instance_id, &token_id_str) + .await + .unwrap(); + + assert_eq!( + escrow_balance, proxy_transfer_amount, + "Escrow instance should have received the transferred tokens" + ); +} + +/// Test that FT transfer succeeds when relay authorizes via on_auth call +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn test_ft_transfer_authorized_by_relay() { + let env = Env::builder().build().await; + + // Deploy global contracts in parallel + let (condvar_global, ft_receiver_global) = futures::join!( + env.root().deploy_oneshot_condvar("global_transfer_auth"), + env.root() + .deploy_mt_receiver_stub_global("ft_receiver_global"), + ); + + // Create accounts in parallel + let (solver, relay, proxy) = futures::join!( + env.create_named_user("solver"), + env.create_named_user("relay"), + env.create_named_user("proxy"), + ); + + // Use root as auth_contract since we need signing capability for on_auth call + let config = ProxyConfig { + owner: proxy.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId(condvar_global.clone()), + escrow_swap_contract_id: GlobalContractId::AccountId(ft_receiver_global.clone()), + auth_contract: env.root().id().clone(), + auth_collee: relay.id().clone(), + }; + + proxy.deploy_escrow_proxy(config.clone()).await.unwrap(); + + // Derive and pre-deploy the escrow instance (ft-receiver-stub) + let escrow_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(ft_receiver_global.clone()), + data: BTreeMap::new(), + }); + let escrow_instance_id = escrow_state_init.derive_account_id(); + + // Deploy escrow instance via state_init + env.root() + .tx(escrow_instance_id.clone()) + .state_init(ft_receiver_global.clone(), BTreeMap::new()) + .transfer(NearToken::from_yoctonear(1)) + .await + .unwrap(); + + // Create FT token with initial balance for solver + let initial_balance: u128 = 1_000_000; + let (ft_token, _) = env + .create_ft_token_with_initial_balances([(solver.id().clone(), initial_balance)]) + .await + .unwrap(); + + // Storage deposit for proxy and escrow on the FT token + let (proxy_storage, escrow_storage) = futures::join!( + solver.storage_deposit( + ft_token.id(), + Some(proxy.id().as_ref()), + NearToken::from_near(1) + ), + solver.storage_deposit( + ft_token.id(), + Some(escrow_instance_id.as_ref()), + NearToken::from_near(1) + ), + ); + proxy_storage.unwrap(); + escrow_storage.unwrap(); + + // Record initial solver balance + let initial_solver_balance = ft_token.ft_balance_of(solver.id()).await.unwrap(); + + let inner_msg_json = serde_json::to_string(&FTReceiverMode::AcceptAll).unwrap(); + + // Build TransferMessage for the proxy (wraps inner message) + let transfer_msg = TransferMessage { + receiver_id: escrow_instance_id.clone(), + salt: [3u8; 32], // Different salt from other tests + msg: inner_msg_json, + }; + let msg_json = serde_json::to_string(&transfer_msg).unwrap(); + + let proxy_transfer_amount: u128 = 100_000; + + // Derive the transfer-auth instance address (same logic as proxy uses) + // For FT, token_ids is a single-element vec with the FT contract account as string + let context_hash = CondVarContext { + sender_id: Cow::Borrowed(solver.id()), + token_ids: Cow::Owned(vec![ft_token.id().to_string()]), + amounts: Cow::Owned(vec![U128(proxy_transfer_amount)]), + salt: transfer_msg.salt, + msg: Cow::Borrowed(&msg_json), + } + .hash(); + + let auth_state = CondVarStateInit { + escrow_contract_id: config.escrow_swap_contract_id.clone(), + auth_contract: config.auth_contract.clone(), + notifier_id: config.auth_collee.clone(), + authorizee: proxy.id().clone(), + msg_hash: context_hash, + }; + let condvar_instance_id = derive_oneshot_condvar_account_id( + &GlobalContractId::AccountId(condvar_global.clone()), + &auth_state, + ); + + // Build raw state for state_init (same as proxy does) + let raw_state = ContractStorage::init_state(auth_state).unwrap(); + + // Run transfer and on_auth call concurrently + // Transfer starts the yield promise, on_auth authorizes it + let (_transfer_result, _auth_result) = futures::join!( + solver.ft_transfer_call( + ft_token.id(), + proxy.id(), + proxy_transfer_amount, + None, + &msg_json, + ), + // Call on_auth from root (auth_contract) with relay as signer_id + // Include state_init to deploy the transfer-auth instance if not already deployed + async { + env.root() + .tx(condvar_instance_id.clone()) + .state_init(condvar_global.clone(), raw_state) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(serde_json::json!({ + "signer_id": relay.id(), + "msg": "", + })) + .with_gas(Gas::from_tgas(50)) + .with_deposit(NearToken::from_yoctonear(1)), + ) + .await + .unwrap(); + } + ); + + // Verify solver balance decreased + let final_solver_balance = ft_token.ft_balance_of(solver.id()).await.unwrap(); + + assert_eq!( + initial_solver_balance - proxy_transfer_amount, + final_solver_balance, + "Solver balance should decrease by transferred amount" + ); + + // Verify escrow instance received the tokens + let escrow_balance = ft_token.ft_balance_of(&escrow_instance_id).await.unwrap(); + + assert_eq!( + escrow_balance, proxy_transfer_amount, + "Escrow instance should have received the transferred tokens" + ); +} diff --git a/tests/src/tests/escrow_with_proxy/mod.rs b/tests/src/tests/escrow_with_proxy/mod.rs new file mode 100644 index 000000000..c15ca19b0 --- /dev/null +++ b/tests/src/tests/escrow_with_proxy/mod.rs @@ -0,0 +1,4 @@ +#![allow(async_fn_in_trait)] + +mod swap; +mod swap_with_fees; diff --git a/tests/src/tests/escrow_with_proxy/swap.rs b/tests/src/tests/escrow_with_proxy/swap.rs new file mode 100644 index 000000000..0ba7fe2a9 --- /dev/null +++ b/tests/src/tests/escrow_with_proxy/swap.rs @@ -0,0 +1,285 @@ +//! Integration tests for escrow-swap with escrow-proxy using near-sandbox. +//! +//! This module tests the full flow of: +//! 1. Maker creating an escrow with tokens they want to swap +//! 2. Solver filling the escrow via the proxy with relay authorization +//! 3. Atomic token exchange between maker and solver + +use std::borrow::Cow; +use std::time::Duration; + +use crate::tests::defuse::DefuseSignerExt; +use crate::tests::defuse::env::Env; +use defuse::sandbox_ext::intents::ExecuteIntentsExt; +use defuse_core::Deadline; +use defuse_core::amounts::Amounts; +use defuse_core::intents::auth::AuthCall; +use defuse_core::intents::tokens::{NotifyOnTransfer, Transfer}; +use defuse_escrow_proxy::{ProxyConfig, TransferMessage as ProxyTransferMessage}; +use defuse_escrow_swap::ParamsBuilder; +use defuse_escrow_swap::action::{FillMessageBuilder, FundMessageBuilder}; +use defuse_oneshot_condvar::CondVarContext; +use defuse_oneshot_condvar::storage::{ + ContractStorage as CondVarStorage, StateInit as CondVarState, +}; +use defuse_sandbox::{EscrowProxyExt, EscrowSwapExt, MtExt, MtViewExt, OneshotCondVarExt}; +use defuse_token_id::TokenId; +use defuse_token_id::nep245::Nep245TokenId; + +use near_sdk::json_types::U128; +use near_sdk::state_init::{StateInit, StateInitV1}; +use near_sdk::{GlobalContractId, NearToken}; + +/// Test full escrow swap flow with proxy authorization +#[tokio::test] +async fn test_escrow_swap_with_proxy_full_flow() { + let swap_amount: u128 = 100_000_000; // Fits within ft_deposit_to_root mint limit (1e9) + let env = Env::builder().build().await; + let (condvar_global, escrow_swap_global) = futures::join!( + env.root().deploy_oneshot_condvar("oneshot_condvar"), + env.root().deploy_escrow_swap_global("escrow_swap"), + ); + let (maker, solver, relay, proxy) = futures::join!( + env.create_named_user("maker"), + env.create_named_user("solver"), + env.create_named_user("relay"), + env.create_named_user("proxy"), + ); + + let (token_a_result, token_b_result) = futures::join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), swap_amount)]), + env.create_mt_token_with_initial_balances([(solver.id().clone(), swap_amount)]), + ); + let (_, token_a_defuse_id) = token_a_result.unwrap(); + let (_, token_b_defuse_id) = token_b_result.unwrap(); + + let config = ProxyConfig { + owner: proxy.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId(condvar_global.clone()), + escrow_swap_contract_id: GlobalContractId::AccountId(escrow_swap_global.clone()), + auth_contract: env.defuse.id().clone(), + auth_collee: relay.id().clone(), + }; + proxy.deploy_escrow_proxy(config.clone()).await.unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + token_a_defuse_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + token_b_defuse_id.to_string(), + )); + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([proxy.id().clone()], dst_token), + ) + .build(); + let fund_escrow_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + let fill_escrow_msg = FillMessageBuilder::new(escrow_params.clone()) + .with_deadline(Deadline::timeout(Duration::from_secs(120))) + .build(); + + let fund_msg_json = serde_json::to_string(&fund_escrow_msg).unwrap(); + + let escrow_raw_state = defuse_escrow_swap::ContractStorage::init_state(&escrow_params).unwrap(); + let escrow_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(escrow_swap_global.clone()), + data: escrow_raw_state, + }); + let escrow_instance_id = escrow_state_init.derive_account_id(); + + let transfer = Transfer { + receiver_id: escrow_instance_id.clone(), + tokens: Amounts::new([(token_a_defuse_id.clone(), swap_amount)].into()), + memo: None, + notification: Some(NotifyOnTransfer::new(fund_msg_json).with_state_init(escrow_state_init)), + }; + + // Maker signs and executes transfer intent + let transfer_payload = maker + .sign_defuse_payload_default(&env.defuse, [transfer]) + .await + .unwrap(); + maker + .simulate_and_execute_intents(env.defuse.id(), [transfer_payload]) + .await + .unwrap(); + + let proxy_msg = ProxyTransferMessage { + receiver_id: escrow_instance_id.clone(), + salt: [2u8; 32], + msg: serde_json::to_string(&fill_escrow_msg).unwrap(), + }; + let proxy_msg_json = serde_json::to_string(&proxy_msg).unwrap(); + + let context_hash = CondVarContext { + sender_id: Cow::Borrowed(solver.id().as_ref()), + token_ids: Cow::Owned(vec![token_b_defuse_id.to_string()]), + amounts: Cow::Owned(vec![U128(swap_amount)]), + salt: proxy_msg.salt, + msg: Cow::Borrowed(&proxy_msg_json), + } + .hash(); + + let auth_state = CondVarState { + escrow_contract_id: config.escrow_swap_contract_id.clone(), + auth_contract: env.defuse.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: context_hash, + }; + let condvar_raw_state = CondVarStorage::init_state(auth_state.clone()).unwrap(); + let condvar_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(condvar_global.clone()), + data: condvar_raw_state, + }); + + let auth_payload = relay + .sign_defuse_payload_default( + &env.defuse, + [AuthCall { + contract_id: condvar_state_init.derive_account_id(), + state_init: Some(condvar_state_init), + msg: String::new(), + attached_deposit: NearToken::from_yoctonear(0), + min_gas: None, + }], + ) + .await + .unwrap(); + relay + .simulate_and_execute_intents(env.defuse.id(), [auth_payload]) + .await + .unwrap(); + + solver + .mt_transfer_call( + env.defuse.id(), + proxy.id(), + &token_b_defuse_id.to_string(), + swap_amount, + None, + &proxy_msg_json, + ) + .await + .unwrap(); + + assert_eq!( + env.defuse + .mt_balance_of(maker.id(), &token_b_defuse_id.to_string()) + .await + .unwrap(), + swap_amount, + ); + + assert_eq!( + env.defuse + .mt_balance_of(proxy.id(), &token_a_defuse_id.to_string()) + .await + .unwrap(), + swap_amount, + "Proxy (taker) should have received token-a" + ); +} + +/// Test that escrow proxy (as sole taker) can cancel escrow before deadline +#[tokio::test] +async fn test_escrow_proxy_can_cancel_before_deadline() { + let swap_amount: u128 = 100_000_000; + let env = Env::builder().build().await; + + let escrow_swap_global = env.root().deploy_escrow_swap_global("escrow_swap").await; + let (maker, proxy) = futures::join!( + env.create_named_user("maker"), + env.create_named_user("proxy"), + ); + + // Create two tokens: token_a for maker, token_b as dummy dst (required by escrow) + let (token_a_result, token_b_result) = futures::join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), swap_amount)]), + env.create_mt_token_with_initial_balances([]), // no initial balance needed + ); + let (_, token_a_defuse_id) = token_a_result.unwrap(); + let (_, token_b_defuse_id) = token_b_result.unwrap(); + + // Deploy proxy with root as owner (can call cancel_escrow) + let config = ProxyConfig { + owner: env.root().id().clone(), + // NOTE: per_fill_contract_id is only used for fill operations. + // This cancel test doesn't exercise fills, so using escrow_swap_global is acceptable. + per_fill_contract_id: GlobalContractId::AccountId(escrow_swap_global.clone()), + escrow_swap_contract_id: GlobalContractId::AccountId(escrow_swap_global.clone()), + auth_contract: env.defuse.id().clone(), + auth_collee: env.root().id().clone(), // not used for cancel + }; + proxy.deploy_escrow_proxy(config).await.unwrap(); + + // Build escrow params with proxy as sole taker + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + token_a_defuse_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + token_b_defuse_id.to_string(), + )); + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([proxy.id().clone()], dst_token), + ) + .build(); + + let fund_escrow_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + let fund_msg_json = serde_json::to_string(&fund_escrow_msg).unwrap(); + + let escrow_raw_state = defuse_escrow_swap::ContractStorage::init_state(&escrow_params).unwrap(); + let escrow_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(escrow_swap_global.clone()), + data: escrow_raw_state, + }); + let escrow_instance_id = escrow_state_init.derive_account_id(); + + // Fund the escrow + let transfer = Transfer { + receiver_id: escrow_instance_id.clone(), + tokens: Amounts::new([(token_a_defuse_id.clone(), swap_amount)].into()), + memo: None, + notification: Some(NotifyOnTransfer::new(fund_msg_json).with_state_init(escrow_state_init)), + }; + + let transfer_payload = maker + .sign_defuse_payload_default(&env.defuse, [transfer]) + .await + .unwrap(); + maker + .simulate_and_execute_intents(env.defuse.id(), [transfer_payload]) + .await + .unwrap(); + + // Verify maker's tokens are in escrow (balance is 0) + assert_eq!( + env.defuse + .mt_balance_of(maker.id(), &token_a_defuse_id.to_string()) + .await + .unwrap(), + 0, + "Maker should have 0 balance after funding escrow" + ); + + // Root (super_admin) cancels the escrow via proxy + env.root() + .cancel_escrow(proxy.id(), &escrow_params) + .await + .unwrap(); + + // Verify maker got their tokens back + assert_eq!( + env.defuse + .mt_balance_of(maker.id(), &token_a_defuse_id.to_string()) + .await + .unwrap(), + swap_amount, + "Maker should have tokens back after cancel" + ); +} diff --git a/tests/src/tests/escrow_with_proxy/swap_with_fees.rs b/tests/src/tests/escrow_with_proxy/swap_with_fees.rs new file mode 100644 index 000000000..4a7f2df1a --- /dev/null +++ b/tests/src/tests/escrow_with_proxy/swap_with_fees.rs @@ -0,0 +1,213 @@ +//! Gas benchmark test for escrow swap with proxy flow. +//! +//! Tests the full proxy → condvar → escrow path to measure total gas consumption. +//! Uses worst-case scenario with protocol fees, integrator fees, and surplus fees. + +use std::borrow::Cow; +use std::time::Duration; + +use crate::tests::defuse::DefuseSignerExt; +use crate::tests::defuse::env::Env; +use defuse::sandbox_ext::intents::ExecuteIntentsExt; +use defuse_core::Deadline; +use defuse_core::amounts::Amounts; +use defuse_core::intents::auth::AuthCall; +use defuse_core::intents::tokens::{NotifyOnTransfer, Transfer}; +use defuse_escrow_proxy::{ProxyConfig, TransferMessage as ProxyTransferMessage}; +use defuse_escrow_swap::ParamsBuilder; +use defuse_escrow_swap::action::{FillAction, FundMessageBuilder, TransferAction, TransferMessage}; +use defuse_escrow_swap::decimal::UD128; +use defuse_escrow_swap::{OverrideSend, Pips, ProtocolFees}; +use defuse_oneshot_condvar::CondVarContext; +use defuse_oneshot_condvar::storage::{ + ContractStorage as CondVarStorage, StateInit as CondVarState, +}; +use defuse_sandbox::{EscrowProxyExt, EscrowSwapExt, MtExt, MtViewExt, OneshotCondVarExt}; +use defuse_token_id::TokenId; +use defuse_token_id::nep245::Nep245TokenId; + +use near_sdk::json_types::U128; +use near_sdk::state_init::{StateInit, StateInitV1}; +use near_sdk::{Gas, GlobalContractId, NearToken}; + +/// Benchmark test for proxy fill gas consumption (worst case with all fees). +/// Tests the full flow: solver → proxy → condvar → escrow with: +/// - Protocol fee (1%) +/// - Integrator fee (2%) +/// - Surplus fee (50%) - triggered by higher fill price +#[tokio::test] +async fn test_proxy_fill_gas_benchmark() { + let swap_amount: u128 = 100_000_000; + let solver_amount: u128 = swap_amount * 2; // 2x for price 2.0 fill + let env = Env::builder().build().await; + let (condvar_global, escrow_swap_global) = futures::join!( + env.root().deploy_oneshot_condvar("oneshot_condvar"), + env.root().deploy_escrow_swap_global("escrow_swap"), + ); + let (maker, solver, relay, proxy, fee_collector, integrator) = futures::join!( + env.create_named_user("maker"), + env.create_named_user("solver"), + env.create_named_user("relay"), + env.create_named_user("proxy"), + env.create_named_user("fee_collector"), + env.create_named_user("integrator"), + ); + + let (token_a_result, token_b_result) = futures::join!( + env.create_mt_token_with_initial_balances([(maker.id().clone(), swap_amount)]), + env.create_mt_token_with_initial_balances([(solver.id().clone(), solver_amount)]), + ); + let (_, token_a_defuse_id) = token_a_result.unwrap(); + let (_, token_b_defuse_id) = token_b_result.unwrap(); + + let config = ProxyConfig { + owner: proxy.id().clone(), + per_fill_contract_id: GlobalContractId::AccountId(condvar_global.clone()), + escrow_swap_contract_id: GlobalContractId::AccountId(escrow_swap_global.clone()), + auth_contract: env.defuse.id().clone(), + auth_collee: relay.id().clone(), + }; + proxy.deploy_escrow_proxy(config.clone()).await.unwrap(); + + let src_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + token_a_defuse_id.to_string(), + )); + let dst_token = TokenId::from(Nep245TokenId::new( + env.defuse.id().clone(), + token_b_defuse_id.to_string(), + )); + // Configure escrow with all fee types for worst-case gas scenario + let escrow_params = ParamsBuilder::new( + (maker.id().clone(), src_token), + ([proxy.id().clone()], dst_token), + ) + .with_protocol_fees(ProtocolFees { + fee: Pips::from_percent(1).unwrap(), // 1% protocol fee + surplus: Pips::from_percent(50).unwrap(), // 50% surplus fee + collector: fee_collector.id().clone(), + }) + .with_integrator_fee(integrator.id().clone(), Pips::from_percent(2).unwrap()) + .build(); + let fund_escrow_msg = FundMessageBuilder::new(escrow_params.clone()).build(); + // Fill at price 2.0 to trigger surplus fee (maker price is 1.0) + let fill_escrow_msg = TransferMessage { + params: escrow_params.clone(), + action: TransferAction::Fill(FillAction { + price: UD128::from(2), // 2x maker's price + deadline: Deadline::timeout(Duration::from_secs(120)), + receive_src_to: OverrideSend::default(), + }), + }; + + let fund_msg_json = serde_json::to_string(&fund_escrow_msg).unwrap(); + + let escrow_raw_state = defuse_escrow_swap::ContractStorage::init_state(&escrow_params).unwrap(); + let escrow_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(escrow_swap_global.clone()), + data: escrow_raw_state, + }); + let escrow_instance_id = escrow_state_init.derive_account_id(); + + let transfer = Transfer { + receiver_id: escrow_instance_id.clone(), + tokens: Amounts::new([(token_a_defuse_id.clone(), swap_amount)].into()), + memo: None, + notification: Some(NotifyOnTransfer::new(fund_msg_json).with_state_init(escrow_state_init)), + }; + + // Maker signs and executes transfer intent to fund escrow + let transfer_payload = maker + .sign_defuse_payload_default(&env.defuse, [transfer]) + .await + .unwrap(); + maker + .simulate_and_execute_intents(env.defuse.id(), [transfer_payload]) + .await + .unwrap(); + + let proxy_msg = ProxyTransferMessage { + receiver_id: escrow_instance_id.clone(), + salt: [2u8; 32], + msg: serde_json::to_string(&fill_escrow_msg).unwrap(), + }; + let proxy_msg_json = serde_json::to_string(&proxy_msg).unwrap(); + + let context_hash = CondVarContext { + sender_id: Cow::Borrowed(solver.id().as_ref()), + token_ids: Cow::Owned(vec![token_b_defuse_id.to_string()]), + amounts: Cow::Owned(vec![U128(solver_amount)]), // 2x for price 2.0 + salt: proxy_msg.salt, + msg: Cow::Borrowed(&proxy_msg_json), + } + .hash(); + + let auth_state = CondVarState { + escrow_contract_id: config.escrow_swap_contract_id.clone(), + auth_contract: env.defuse.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: context_hash, + }; + let condvar_raw_state = CondVarStorage::init_state(auth_state.clone()).unwrap(); + let condvar_state_init = StateInit::V1(StateInitV1 { + code: GlobalContractId::AccountId(condvar_global.clone()), + data: condvar_raw_state, + }); + + // Relay signs authorization + let auth_payload = relay + .sign_defuse_payload_default( + &env.defuse, + [AuthCall { + contract_id: condvar_state_init.derive_account_id(), + state_init: Some(condvar_state_init), + msg: String::new(), + attached_deposit: NearToken::from_yoctonear(0), + min_gas: None, + }], + ) + .await + .unwrap(); + relay + .simulate_and_execute_intents(env.defuse.id(), [auth_payload]) + .await + .unwrap(); + + // Measure the solver's mt_transfer_call to proxy - this is the key operation + let result = solver + .mt_transfer_call_exec( + env.defuse.id(), + proxy.id(), + &token_b_defuse_id.to_string(), + solver_amount, // 2x for price 2.0 + None, + &proxy_msg_json, + ) + .await + .unwrap(); + + let total_gas = Gas::from_gas(result.total_gas_burnt.as_gas()); + + eprintln!("Proxy fill total gas consumed (worst case with fees): {total_gas:?}"); + + // Verify swap completed successfully - proxy received src tokens + assert!( + env.defuse + .mt_balance_of(proxy.id(), &token_a_defuse_id.to_string()) + .await + .unwrap() + > 0, + "Proxy (taker) should have received token-a" + ); + + // Verify maker received dst tokens (minus fees) + assert!( + env.defuse + .mt_balance_of(maker.id(), &token_b_defuse_id.to_string()) + .await + .unwrap() + > 0, + "Maker should have received token-b" + ); +} diff --git a/tests/src/tests/mod.rs b/tests/src/tests/mod.rs index 3070221b4..9d21d2f44 100644 --- a/tests/src/tests/mod.rs +++ b/tests/src/tests/mod.rs @@ -1,4 +1,7 @@ pub mod defuse; pub mod escrow; +pub mod escrow_proxy; +pub mod escrow_with_proxy; +pub mod oneshot_condvar; pub mod poa; pub mod utils; diff --git a/tests/src/tests/oneshot_condvar/mod.rs b/tests/src/tests/oneshot_condvar/mod.rs new file mode 100644 index 000000000..13ee0d5d2 --- /dev/null +++ b/tests/src/tests/oneshot_condvar/mod.rs @@ -0,0 +1,478 @@ +use std::time::Duration; + +use defuse_oneshot_condvar::WAIT_GAS; +use defuse_oneshot_condvar::storage::StateInit as CondVarStateInit; +use defuse_sandbox::{Account, FnCallBuilder, OneshotCondVarExt, Sandbox}; +use near_sdk::{AccountId, Gas, GlobalContractId, NearToken, serde_json::json}; + +const INIT_BALANCE: NearToken = NearToken::from_near(100); + +#[tokio::test] +async fn on_auth_call() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + let network_config = root.network_config().clone(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + // unauthorized contract (relay vs auth_contract) + relay + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap_err(); + + // unauthorized callee (auth_contract vs relay) + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": auth_contract.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap_err(); + + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_ok() + ); +} + +#[tokio::test] +async fn oneshot_condvar_early_notification() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + let network_config = root.network_config().clone(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + + proxy + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_err() + ); +} + +#[tokio::test] +async fn oneshot_condvar_async_notification() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let network_config = root.network_config().clone(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + let authorized = tokio::spawn({ + let condvar_instance = condvar_instance.clone(); + async move { + proxy + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap() + } + }); + + // replace with waiting for couple blocks + tokio::time::sleep(Duration::from_secs(3)).await; + + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_ok() + ); + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + + authorized.await.unwrap(); + + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_err() + ); +} + +#[tokio::test] +async fn oneshot_condvar_async_notification_timeout() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + let network_config = root.network_config().clone(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + let cv_wait = proxy.tx(condvar_instance.clone()).function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ); + + let forward_time = sandbox.fast_forward(200); + + let (authorized, ()) = futures::join!(async { cv_wait.await }, forward_time); + assert!(!authorized.unwrap().json::().unwrap()); + + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_ok() + ); +} + +#[tokio::test] +async fn oneshot_condvar_retry_after_timeout_with_on_auth() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + let network_config = root.network_config().clone(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + // First cv_wait - will timeout + let cv_wait = proxy.tx(condvar_instance.clone()).function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ); + + let forward_time = sandbox.fast_forward(200); + + let (authorized, ()) = futures::join!(async { cv_wait.await }, forward_time); + + // First attempt should timeout and return false + assert!(authorized.unwrap().json::().is_ok()); + + // Now call on_auth before second cv_wait + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + + // Second cv_wait should succeed immediately (early authorization path) + let result = proxy + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + assert!(result.json::().unwrap()); + + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_err() + ); +} + +#[tokio::test] +async fn oneshot_condvar_retry_after_timeout_with_on_auth2() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + let network_config = root.network_config().clone(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + // First cv_wait - will timeout + let cv_wait = proxy.tx(condvar_instance.clone()).function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ); + let forward_time = sandbox.fast_forward(200); + let (authorized, ()) = futures::join!(async { cv_wait.await }, forward_time); + // First attempt should timeout and return false + assert!(authorized.unwrap().json::().is_ok()); + + let cv_wait = proxy.tx(condvar_instance.clone()).function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ); + + let (authorized, ()) = futures::join!(async { cv_wait.await }, async { + tokio::time::sleep(Duration::from_secs(3)).await; + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + }); + + assert!(authorized.unwrap().json::().unwrap()); + + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_err() + ); +} + +/// Gas benchmark for cv_wait in worst case (wait first, notify later). +/// This measures gas for the cv_wait transaction that creates a yielded promise. +#[tokio::test] +async fn test_cv_wait_gas_benchmark() { + let sandbox = Sandbox::new("test".parse::().unwrap()).await; + let root = sandbox.root(); + + let condvar_global = root.deploy_oneshot_condvar("auth").await; + + let (escrow, auth_contract, relay, proxy) = futures::try_join!( + root.generate_subaccount("escrow", INIT_BALANCE), + root.generate_subaccount("auth-contract", INIT_BALANCE), + root.generate_subaccount("auth-callee", INIT_BALANCE), + root.generate_subaccount("proxy", INIT_BALANCE), + ) + .unwrap(); + + let network_config = root.network_config().clone(); + + let state = CondVarStateInit { + escrow_contract_id: GlobalContractId::AccountId(escrow.id().clone()), + auth_contract: auth_contract.id().clone(), + notifier_id: relay.id().clone(), + authorizee: proxy.id().clone(), + msg_hash: [0; 32], + }; + + let condvar_instance = root + .deploy_oneshot_condvar_instance(condvar_global.clone(), state) + .await; + + // Measure cv_wait gas (worst case: wait first, creates yielded promise) + let cv_wait_result = proxy + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("cv_wait") + .json_args(json!({})) + .with_gas(Gas::from_tgas(300)), + ) + .exec_transaction(); + + // Notify after wait is pending + let notify_task = async { + tokio::time::sleep(Duration::from_secs(3)).await; + auth_contract + .tx(condvar_instance.clone()) + .function_call( + FnCallBuilder::new("on_auth") + .json_args(json!({ "signer_id": relay.id(), "msg": "" })) + .with_gas(Gas::from_tgas(300)), + ) + .await + .unwrap(); + }; + + let (cv_wait_exec_result, ()) = futures::join!(cv_wait_result, notify_task); + let result = cv_wait_exec_result.unwrap(); + + let total_gas = Gas::from_gas(result.total_gas_burnt.as_gas()); + eprintln!("cv_wait total gas consumed: {total_gas:?}"); + + // Verify authorization succeeded + assert!(result.into_result().unwrap().json::().unwrap()); + + // Verify contract was cleaned up + sandbox.fast_forward(5).await; + assert!( + Account::new(condvar_instance.clone(), network_config.clone()) + .view() + .await + .is_err() + ); + + assert!( + WAIT_GAS >= total_gas, + "WAIT_GAS ({WAIT_GAS:?}) should be >= actual ({total_gas:?})", + ); +}