Repository navigation
[contract] - enforce one active sub-escrow per buyer per trade in deposit_to_escrow (#294) - #428
Merged
dark-sarge merged 7 commits intoOct 1, 2026
Conversation
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>
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
deposit_to_escrowonly 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_fillguard runs before any tokensmove, 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 featurefix— bug fixrefactor— code change with no behaviour changedocs— documentation only (contracts/ERROR_CODES.md)chore— build, deps, config (the CI-unblock + baseline-repair commits, see Notes for Reviewer)contract— Soroban smart contract changeWhat Changed.
contracts/escrow/src/lib.rsContractError::DuplicateFill = 16(appended, never renumbered) andrequire_no_active_fill(env, trade_id, buyer), which walksTradeFillCounter(trade_id)→SubEscrow(trade_id, i)and returnsDuplicateFillif any sub-escrow hasbuyer == *buyer && !released && !refunded.deposit_to_escrowcalls it immediately after the capacity check and beforetoken_client.transfer, so a refused duplicate is a pure no-op on both capacity and balances. Three new tests (below).contracts/ERROR_CODES.mdContractErrorenum. The old table had drifted entirely: it listed1 / 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 codes1–16against the enum, notes that15–16are escrow-only, and states the append-only rule for new variants.server/src/services/contractErrors.tsPauseCooldownNotExpiredError(15) andDuplicateFillError(16) classes and registers both inERROR_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.rsTests added
test_deposit_to_escrow_rejects_duplicate_fill_by_same_buyerErr(Ok(ContractError::DuplicateFill)), and afterwardsfilled_amountis still100_0000000, status is stillPartiallyFilled, 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_fillsPartiallyFilled→Locked; each fill is independently releasable and the trade reachesCompletedwith the seller holding the full500_0000000.test_deposit_to_escrow_allows_refill_after_releaseFillAlreadyProcessed— the settled fill stays settled.How to Test
Manual check against a running contract:
Checklist
General
tsc --noEmit)cargo fmtclean, clippy-D warningsclean).env.exampleupdated if new env vars were added (none added)API changes
Smart contract changes
contracts/ERROR_CODES.mdupdated to match the enumDatabase changes
SubEscrowstorageDocs changes
contracts/ERROR_CODES.mdrewritten against the real enumNotes for Reviewer
Why
DuplicateFilland notUnauthorized. The issue allows either. Adistinct code was chosen because
Unauthorized(2) already means "wrong signeror 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 actionablemessage 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 publiclisting 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_idindex; that would be a storage-layoutchange 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.
DuplicateFillis appendedas
16— no existing variant was renumbered, so already-deployed callers thatdecode 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:
67f7f65fix(server,frontend): repair CI breakage left by recent merges —maindid not typecheck (getServerKeypairdeclared twice) and 5 of 25server 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.
53f24d0chore(ci): refresh pnpm-lock.yaml —pnpm install --frozen-lockfile, the first step of both workflows, failed withERR_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.b6351c8ci(server): drop the Vitest-only--runflag — Jest aborted withUnrecognized CLI Parameter, so
test-results.jsonwas 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.
dc8e10ctest(frontend): commit the EscrowTransactionLink snapshot — thesuite called
toMatchSnapshot()but the generated.snapwas never tracked.Locally Jest silently rewrites it and passes; under GitHub's
CI=trueitrefuses 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). Pristinemaindoes not compile itsown contracts:
escrow/src/lib.rsreferencedtopic_token/topic_allowed/topic_removed(never defined) and aDataKey::LastPauseAtvariant that doesnot exist (the enum has
PausedAt), andpause()read anowbinding scopedinside an
ifblock. Both test suites were also written against the oldsoroban-sdk 22 shape —
Ok(Err(ContractError::X))instead of the currentErr(Ok(ContractError::X))— usedsac.balancewhere the SAC no longer exposesit, and used
env.register_contract(None, …)(deprecated in favour ofenv.register(…, ())). Without this commitcargo testcannot run at all, soit 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 onmainand on everybranch: no ESLint configuration is tracked anywhere in the repo, so
next lintopens 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.