Skip to content

[contract] - enforce one active sub-escrow per buyer per trade in deposit_to_escrow (#294) - #428

Merged
dark-sarge merged 7 commits into
arflexx:mainfrom
CodingBabe-1:fix/294-escrow-duplicate-fill
Oct 1, 2026
Merged

dark-sarge merged 7 commits into
arflexx:mainfrom
CodingBabe-1:fix/294-escrow-duplicate-fill

Conversation

@CodingBabe-1

Copy link
Copy Markdown
Contributor

Summary

deposit_to_escrow only ever validated the remaining capacity of a listing.
Each call was checked in isolation, so one buyer could call it repeatedly with
small amounts — every one of them individually inside the remaining capacity —
and end up holding several concurrent sub-escrows on the same trade, taking over
a public listing that is meant to be shared by many buyers.

This enforces the rule the issue asks for: at most one active sub-escrow per
buyer per trade.
A new require_no_active_fill guard runs before any tokens
move, and a buyer who already holds a fill that is neither released nor refunded
is rejected with a new ContractError::DuplicateFill = 16.

The guard counts only active fills, so the limit is not "has ever deposited"
but "currently holds a place": once a fill is settled (released or refunded) the
buyer is free to buy into a still-partially-filled listing again.

Closes #294


Type of Change

  • feat — new feature
  • fix — bug fix
  • refactor — code change with no behaviour change
  • docs — documentation only (contracts/ERROR_CODES.md)
  • chore — build, deps, config (the CI-unblock + baseline-repair commits, see Notes for Reviewer)
  • contract — Soroban smart contract change

What Changed.

File Change
contracts/escrow/src/lib.rs Adds ContractError::DuplicateFill = 16 (appended, never renumbered) and require_no_active_fill(env, trade_id, buyer), which walks TradeFillCounter(trade_id) → SubEscrow(trade_id, i) and returns DuplicateFill if any sub-escrow has buyer == *buyer && !released && !refunded. deposit_to_escrow calls it immediately after the capacity check and before token_client.transfer, so a refused duplicate is a pure no-op on both capacity and balances. Three new tests (below).
contracts/ERROR_CODES.md Rewritten to match the real ContractError enum. The old table had drifted entirely: it listed 1 / 101, 2 / 102 … dual numbering that does not exist, with names (Unauthorized = 1, InvalidStatus = 4) that contradict the actual discriminants (AlreadyInitialized = 1, WrongStatus = 4). Now documents codes 1–16 against the enum, notes that 15–16 are escrow-only, and states the append-only rule for new variants.
server/src/services/contractErrors.ts Adds typed PauseCooldownNotExpiredError (15) and DuplicateFillError (16) classes and registers both in ERROR_REGISTRY, so the server maps every code the contract can emit back to a typed error instead of falling through to a generic one.
contracts/marketplace/src/lib.rs Test-suite repair only (see Baseline repair) — no marketplace behaviour change.

Tests added

Test What it pins
test_deposit_to_escrow_rejects_duplicate_fill_by_same_buyer The exact monopolising pattern from the issue: 2nd and 3rd fills by the same buyer both return Err(Ok(ContractError::DuplicateFill)), and afterwards filled_amount is still 100_0000000, status is still PartiallyFilled, and the buyer's balance is unchanged (100_000_000_000 - 100_0000000) — i.e. no money moved.
test_deposit_to_escrow_multi_buyer_partial_fills The limit is per buyer, not per trade: 3 buyers fill 200 + 200 + 100 into a 500 listing → PartiallyFilled → Locked; each fill is independently releasable and the trade reaches Completed with the seller holding the full 500_0000000.
test_deposit_to_escrow_allows_refill_after_release "Active" is the rule, not "ever deposited": buyer deposits 200, fill #1 is released while the listing is still open, then a 300 deposit is accepted (not flagged as a duplicate), and releasing fill #1 again correctly returns FillAlreadyProcessed — the settled fill stays settled.

How to Test

# 0. Toolchain note — contracts/.cargo/config.toml forces the wasm target, so
#    every host command must name it explicitly or it won't link/run.
cd contracts

# 1. Formatting (CI gate)
cargo fmt --all -- --check
# → clean

# 2. Lints (CI gate: -D warnings)
cargo clippy --workspace --all-targets --all-features \
  --target x86_64-unknown-linux-gnu -- -D warnings
# → Finished, no warnings

# 3. Tests (CI gate)
cargo test --all-features --target x86_64-unknown-linux-gnu
# → 41 escrow + 29 marketplace = 70 passed, 0 failed

# 4. Wasm release build (CI gate)
cargo build --release --target wasm32v1-none
# → Finished

# 5. Server typecheck + tests
cd ../server && npx tsc --noEmit
npx jest          # → 25 suites / 173 tests pass

Manual check against a running contract:

# Buyer deposits 100 into a 500 listing → OK.
# Same buyer deposits another 100 into the *same* trade → DuplicateFill (16),
# and the buyer's token balance is unchanged by the rejected call.

Checklist

General

  • Code compiles / builds without errors
  • No new TypeScript errors (tsc --noEmit)
  • Follows existing code style and patterns (cargo fmt clean, clippy -D warnings clean)
  • No secrets, keys, or credentials committed
  • .env.example updated if new env vars were added (none added)

API changes

  • N/A — no HTTP route or request/response shape changed
  • Zod validation added for all request inputs (no new inputs)

Smart contract changes

  • New error variant appended (never renumbered — existing discriminants are unchanged, so deployed callers still decode correctly)
  • Guard placed before the token transfer, so a rejected call moves no funds
  • contracts/ERROR_CODES.md updated to match the enum
  • Server-side error registry mirrors every code
  • Tests cover both limit enforcement and multi-buyer partial fills (acceptance criteria)

Database changes

  • N/A — no migration; the rule is derived from existing SubEscrow storage

Docs changes

  • contracts/ERROR_CODES.md rewritten against the real enum

Notes for Reviewer

Why DuplicateFill and not Unauthorized. The issue allows either. A
distinct code was chosen because Unauthorized (2) already means "wrong signer
or wrong role" and is returned by six different checks in this contract; folding
a capacity-policy violation into it would make the failure indistinguishable
from an auth bug in both the server's typed mapping and any client retry logic.
The server now decodes 16 as DuplicateFillError, so callers get an actionable
message rather than a generic 403-flavoured one.

The guard is O(number of fills on this trade). It reads
TradeFillCounter(trade_id) and loops over the sub-escrows. For a public
listing with partial fills that count is small (bounded by listing capacity ÷
minimum fill), and it runs once per deposit before the token transfer. If
listings with very large fill counts ever appear, the natural optimisation is a
secondary (trade_id, buyer) → fill_id index; that would be a storage-layout
change and is deliberately not introduced here, since it would alter the
storage keys already written by deployed state.

Only the escrow contract's discriminants moved. DuplicateFill is appended
as 16 — no existing variant was renumbered, so already-deployed callers that
decode by integer keep working. Marketplace's enum is untouched.

Base commit / shared commits. This branch carries four commits that are not
part of #294 and are included only so CI can actually run:

  1. 67f7f65 fix(server,frontend): repair CI breakage left by recent merges —
    main did not typecheck (getServerKeypair declared twice) and 5 of 25
    server suites failed before any PR could be evaluated. Included unchanged in
    fix(frontend): validate signup phone number inline before submit #423 and fix(trades): convert sell amounts to stroops explicitly (issue #292) #426.
  2. 53f24d0 chore(ci): refresh pnpm-lock.yaml — pnpm install --frozen-lockfile, the first step of both workflows, failed with
    ERR_PNPM_OUTDATED_LOCKFILE. Included in fix(frontend): validate signup phone number inline before submit #423 and fix(trades): convert sell amounts to stroops explicitly (issue #292) #426.
  3. b6351c8 ci(server): drop the Vitest-only --run flag — Jest aborted with
    Unrecognized CLI Parameter, so test-results.json was never written.
    Included in fix(frontend): validate signup phone number inline before submit #423 and fix(trades): convert sell amounts to stroops explicitly (issue #292) #426.
  4. dc8e10c test(frontend): commit the EscrowTransactionLink snapshot — the
    suite called toMatchSnapshot() but the generated .snap was never tracked.
    Locally Jest silently rewrites it and passes; under GitHub's CI=true it
    refuses to write new snapshots and the suite fails on a clean checkout of
    main
    . Tracking the file is the fix. Same commit on [frontend] - PWA manifest does not include screenshots field for richer install prompts on Android Chrome #293.

Because these four are byte-identical on #293, #423, #426 and this branch,
whichever PR lands first lets the others merge without conflict — the shared
commits drop out of each remaining diff automatically.

Baseline contract repair (cd05e7f). Pristine main does not compile its
own contracts: escrow/src/lib.rs referenced topic_token/topic_allowed/
topic_removed (never defined) and a DataKey::LastPauseAt variant that does
not exist (the enum has PausedAt), and pause() read a now binding scoped
inside an if block. Both test suites were also written against the old
soroban-sdk 22 shape — Ok(Err(ContractError::X)) instead of the current
Err(Ok(ContractError::X)) — used sac.balance where the SAC no longer exposes
it, and used env.register_contract(None, …) (deprecated in favour of
env.register(…, ())). Without this commit cargo test cannot run at all, so
it is a prerequisite for demonstrating the #294 tests. It is deliberately kept
separate from the fix commit so the behavioural change stays reviewable on its
own.

Frontend / next lint. Frontend CI's lint step fails on main and on every
branch: no ESLint configuration is tracked anywhere in the repo, so next lint
opens an interactive setup prompt and exits 1. That is pre-existing, unrelated
to either issue in this batch, and fixing it would mean introducing a lint
config and a set of suppressions — a separate, opinionated change. Every other
gate (tsc --noEmit, next build, jest --coverage) passes on this branch.

CodingBabe-1 and others added 6 commits September 30, 2026 03:27
main currently fails `tsc --noEmit` and 5 of 25 server suites, so any PR
against it is red regardless of its own changes. Repairs:

- services/stellar.ts declared getServerKeypair twice (TS2393), and the
  eager module-load key validation from arflexx#313 preempted the structured
  startup check from arflexx#383, making route tests unusable with a dummy key.
  Keep a single lazily-resolved, cached accessor; index.ts/validateEnv
  still owns boot-time validation, and startup now warns (non-fatally).
- token-revocation lookups added by authenticate() were not accounted for
  in the profile/trades route test mocks, shifting queued results (401s).
- createRatingSchema rejected an explicit `comment: null` that the route
  persists as null either way; accept nullish.
- virtualAccount.test.ts did not compile and raced fake timers against
  microtasks in two tests.
- three wallet modal tests queried ambiguous accessible names; the
  withdraw submit button now exposes a single accessible name while
  submitting.

No production behaviour changes beyond the schema/service repairs above.
Both Frontend CI and Server CI start with `pnpm install --frozen-lockfile`,
which now fails with ERR_PNPM_OUTDATED_LOCKFILE because frontend/package.json
added @axe-core/playwright and @storybook/addon-a11y while the lockfile still
listed the removed storybook entry. Every workflow aborted at the install step
before reaching lint, type-check or tests, so no PR could be verified.

Regenerated with the repo's pinned pnpm 10.28.0; the diff is limited to the
three frontend dev-dependency specifiers and their transitive entries.
`pnpm test -- --run --json --outputFile=test-results.json` expands to
`jest --runInBand --forceExit --run ...`, and Jest 29 rejects `--run` with
"Unrecognized CLI Parameter" before executing any suite. No test-results.json
was produced, so the JUnit conversion step failed with ENOENT and the job went
red without ever running the tests it exists to run.

Verified locally with the CI environment (NODE_ENV=test, .env.test): 27 suites
/ 198 tests execute and test-results.xml is written.
…oroban-sdk

`main` cannot build its own contracts: escrow references three undefined event
helpers (`topic_token`, `topic_allowed`, `topic_removed`) and a
`DataKey::LastPauseAt` variant that does not exist, and `pause()` reads a `now`
scoped to the branch above it. Both test suites were written for an older
soroban-sdk (`try_*` used to return the contract error in the inner `Err`,
`StellarAssetClient` exposed `balance`, `mock_invoke` existed, and `vec!` had
to be imported), so neither crate's tests compiled either.

Makes the workspace build and its tests run again:
- escrow lib: define the missing topics, use the existing `DataKey::PausedAt`
  as the pause timestamp/cooldown source, hoist `now` out of the branch.
- both test modules: migrate to the soroban-sdk 22 `try_*` shape
  (`Err(Ok(ContractError::..))`), read balances through `token::Client`, use
  `env.set_auths(&[])`/`MockAuthInvoke` instead of `mock_invoke`,
  `env.register(..)` instead of the deprecated `register_contract`, import
  `vec!`, and derive `PartialEq` for `TradeOffer`/`Listing` so the error
  assertions type-check.
- regroup the integer literals clippy rejects, and fix the
  `test_err_fill_already_processed` setup so the double release it asserts is
  reachable (a fully released single-fill trade is Completed, so the old setup
  could only return WrongStatus).
- apply `cargo fmt --all` so the `Check formatting` gate passes.

Verified with the host target (the crate's `.cargo/config.toml` targets wasm):
fmt --check, clippy -D warnings, the wasm release build and 67 tests all pass.
…xx#294)

`deposit_to_escrow` only validated the *remaining capacity* of a listing, so a
single buyer could call it repeatedly with amounts that each fit that capacity
— the trade's `Status`/single-`fill_id` checks never rejected them — letting one
buyer hold several concurrent sub-escrows on the same trade and monopolise a
public listing.

Adds `ContractError::DuplicateFill = 16` and a `require_no_active_fill` guard
called by `deposit_to_escrow` before any tokens move: a caller that already
holds a sub-escrow on this trade which is neither released nor refunded is
rejected, so a refused duplicate is a no-op on capacity and balances. The rule
is per (trade, buyer) and per *active* fill, so a settled fill (delivered or
refunded) leaves the buyer free to buy into a partially filled listing again.

Tests: duplicate rejection with no money moved, multi-buyer partial fills up to
Locked/Completed, and refill allowed after the previous fill is released.

Also documents code 16 (and the previously undocumented 15) in
`contracts/ERROR_CODES.md` — whose table had drifted from the enum entirely —
and mirrors both in the server's typed error registry.
…s from a clean checkout

The suite asserts toMatchSnapshot() but the generated .snap file was never
tracked, so on a fresh checkout Jest has nothing to compare against. Locally
that silently rewrites the snapshot and passes; under CI=true Jest refuses to
write new snapshots and the suite fails. Track the generated snapshot.

Co-Authored-By: Codebuff <noreply@codebuff.com>
@drips-wave

drips-wave Bot commented Sep 30, 2026

Copy link
Copy Markdown

@CodingBabe-1 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@dark-sarge
dark-sarge merged commit 7ee7bab into arflexx:main Oct 1, 2026
3 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[contract] - deposit_to_escrow allows the same buyer to fill the same trade multiple times monopolising a public listing

2 participants