diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml new file mode 100644 index 0000000..7c00c54 --- /dev/null +++ b/.github/workflows/sdk_tests.yml @@ -0,0 +1,29 @@ +name: SDK Tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + sdk-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: zk/sdk + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Run SDK tests + run: npm test diff --git a/gateway-contract/contracts/core_contract/core.md b/gateway-contract/contracts/core_contract/core.md index e69de29..1ec2f04 100644 --- a/gateway-contract/contracts/core_contract/core.md +++ b/gateway-contract/contracts/core_contract/core.md @@ -0,0 +1,12 @@ +# Core Contract Notes + +## SMT Root Sequencing Requirement + +`register_resolver` enforces strict root sequencing: + +- `public_signals.old_root` must exactly equal the current on-chain SMT root. +- A successful `register_resolver` updates the on-chain root to `public_signals.new_root`. +- Any later call reusing the pre-update root is rejected as stale. + +This replay protection prevents re-submitting proofs against an already-consumed root. In tests, the stale replay path is asserted to panic with `Error(Contract, #4)` (`StaleRoot`). + diff --git a/gateway-contract/contracts/core_contract/src/test.rs b/gateway-contract/contracts/core_contract/src/test.rs index 2252621..c854a76 100644 --- a/gateway-contract/contracts/core_contract/src/test.rs +++ b/gateway-contract/contracts/core_contract/src/test.rs @@ -414,6 +414,43 @@ fn test_register_resolver_stale_root_fails() { client.register_resolver(&caller, &hash, &dummy_proof(&env), &signals); } +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_register_resolver_stale_root_after_first_registration() { + let env = Env::default(); + env.mock_all_auths(); + let (_, client, initial_root) = setup_with_root(&env); + + let caller = Address::generate(&env); + let first_hash = commitment(&env, 70); + let first_new_root = BytesN::from_array(&env, &[71u8; 32]); + + // First registration succeeds and advances SMT root. + client.register_resolver( + &caller, + &first_hash, + &dummy_proof(&env), + &PublicSignals { + old_root: initial_root.clone(), + new_root: first_new_root, + }, + ); + + let second_hash = commitment(&env, 72); + let second_new_root = BytesN::from_array(&env, &[73u8; 32]); + + // Replay with the original old_root must fail as stale. + client.register_resolver( + &caller, + &second_hash, + &dummy_proof(&env), + &PublicSignals { + old_root: initial_root, + new_root: second_new_root, + }, + ); +} + #[test] #[should_panic(expected = "Error(Contract, #6)")] fn test_resolve_stellar_no_address_linked_when_not_set() { diff --git a/gateway-contract/contracts/escrow_contract/src/events.rs b/gateway-contract/contracts/escrow_contract/src/events.rs index 7c97835..42952fe 100644 --- a/gateway-contract/contracts/escrow_contract/src/events.rs +++ b/gateway-contract/contracts/escrow_contract/src/events.rs @@ -165,4 +165,19 @@ impl Events { } .publish(env); } + + /// Emits a DEPOSIT event with topics (symbol!("DEPOSIT"), commitment) + /// and data (owner, amount, new_balance). + pub fn deposit( + env: &Env, + commitment: BytesN<32>, + owner: Address, + amount: i128, + new_balance: i128, + ) { + env.events().publish( + (symbol_short!("DEPOSIT"), commitment), + (owner, amount, new_balance), + ); + } } diff --git a/gateway-contract/contracts/escrow_contract/src/lib.rs b/gateway-contract/contracts/escrow_contract/src/lib.rs index d37b7da..e5b1805 100644 --- a/gateway-contract/contracts/escrow_contract/src/lib.rs +++ b/gateway-contract/contracts/escrow_contract/src/lib.rs @@ -103,6 +103,39 @@ impl EscrowContract { Events::vault_crt(&env, commitment, token, owner); } + /// Deposits tokens into an active vault. + /// + /// The vault owner must authorize the call. Funds are transferred from the + /// owner to this contract and reflected in the vault's tracked balance. + /// + /// ### Errors + /// - `InvalidAmount`: If `amount <= 0`. + /// - `VaultNotFound`: If the vault does not exist. + /// - `VaultInactive`: If the vault has been cancelled/inactivated. + pub fn deposit(env: Env, commitment: BytesN<32>, amount: i128) { + if amount <= 0 { + panic_with_error!(&env, EscrowError::InvalidAmount); + } + + let config = read_vault_config(&env, &commitment) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::VaultNotFound)); + config.owner.require_auth(); + + let mut state = read_vault_state(&env, &commitment) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::VaultNotFound)); + if !state.is_active { + panic_with_error!(&env, EscrowError::VaultInactive); + } + + let token_client = token::Client::new(&env, &config.token); + token_client.transfer(&config.owner, &env.current_contract_address(), &amount); + + state.balance += amount; + write_vault_state(&env, &commitment, &state); + + Events::deposit(&env, commitment, config.owner, amount, state.balance); + } + /// Schedules a payment from one vault to another. /// /// Funds are reserved in the source vault immediately upon scheduling. diff --git a/gateway-contract/contracts/escrow_contract/src/test.rs b/gateway-contract/contracts/escrow_contract/src/test.rs index 74cc779..931baa7 100644 --- a/gateway-contract/contracts/escrow_contract/src/test.rs +++ b/gateway-contract/contracts/escrow_contract/src/test.rs @@ -85,6 +85,102 @@ fn read_vault(env: &Env, contract_id: &Address, id: &BytesN<32>) -> VaultState { }) } +#[test] +fn test_deposit_success() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, client, token, _token_admin, from, _to) = setup_test(&env); + + let owner = Address::generate(&env); + create_vault(&env, &contract_id, &from, &owner, &token, 0); + + let token_admin_client = StellarAssetClient::new(&env, &token); + token_admin_client.mint(&owner, &500); + + client.deposit(&from, &200); + + env.as_contract(&contract_id, || { + let state: VaultState = env + .storage() + .persistent() + .get(&DataKey::VaultState(from.clone())) + .unwrap(); + assert_eq!(state.balance, 200); + }); + + let token_client = TokenClient::new(&env, &token); + assert_eq!(token_client.balance(&owner), 300); + assert_eq!(token_client.balance(&contract_id), 200); + + let deposit_events = env + .events() + .all() + .iter() + .filter(|(event_contract, _, _)| event_contract == &contract_id) + .count(); + assert_eq!(deposit_events, 1); +} + +#[test] +fn test_deposit_zero_amount_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, client, token, _token_admin, from, _to) = setup_test(&env); + + create_vault( + &env, + &contract_id, + &from, + &Address::generate(&env), + &token, + 0, + ); + + let result = client.try_deposit(&from, &0); + assert!(matches!( + result, + Err(Ok(err)) if err == Error::from_contract_error(EscrowError::InvalidAmount as u32) + )); +} + +#[test] +#[should_panic] +fn test_deposit_non_owner_panics() { + let env = Env::default(); + let (contract_id, client, token, _token_admin, from, _to) = setup_test(&env); + + let owner = Address::generate(&env); + create_vault(&env, &contract_id, &from, &owner, &token, 0); + + client.deposit(&from, &10); +} + +#[test] +fn test_deposit_inactive_vault_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, client, token, _token_admin, from, _to) = setup_test(&env); + + let owner = Address::generate(&env); + create_vault(&env, &contract_id, &from, &owner, &token, 0); + + env.as_contract(&contract_id, || { + let state = VaultState { + balance: 0, + is_active: false, + }; + env.storage() + .persistent() + .set(&DataKey::VaultState(from.clone()), &state); + }); + + let result = client.try_deposit(&from, &10); + assert!(matches!( + result, + Err(Ok(err)) if err == Error::from_contract_error(EscrowError::VaultInactive as u32) + )); +} + #[test] fn test_schedule_payment_success() { let env = Env::default(); diff --git a/zk/sdk/jest.config.ts b/zk/sdk/jest.config.ts new file mode 100644 index 0000000..29f71fa --- /dev/null +++ b/zk/sdk/jest.config.ts @@ -0,0 +1,9 @@ +const config = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src"], + testMatch: ["**/__tests__/**/*.test.ts"], + clearMocks: true, +}; + +export default config; diff --git a/zk/sdk/package.json b/zk/sdk/package.json index de9ffaa..9a84938 100644 --- a/zk/sdk/package.json +++ b/zk/sdk/package.json @@ -6,4 +6,4 @@ "dependencies": { "circomlibjs": "^0.1.7" } -} \ No newline at end of file +} diff --git a/zk/sdk/src/__tests__/smoke.test.ts b/zk/sdk/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..f0ccb3a --- /dev/null +++ b/zk/sdk/src/__tests__/smoke.test.ts @@ -0,0 +1,11 @@ +import * as sdk from "../index"; + +declare const describe: (name: string, fn: () => void) => void; +declare const it: (name: string, fn: () => void) => void; +declare const expect: (value: unknown) => { toBeDefined: () => void }; + +describe("zk sdk smoke", () => { + it("imports index without throwing", () => { + expect(sdk).toBeDefined(); + }); +});