Skip to content

Block card double-spending: delay-module + lien-aware card-collateral#14

Draft
yvesfracari wants to merge 2 commits into
mainfrom
feat/block-card-double-spending
Draft

Block card double-spending: delay-module + lien-aware card-collateral#14
yvesfracari wants to merge 2 commits into
mainfrom
feat/block-card-double-spending

Conversation

@yvesfracari

Copy link
Copy Markdown
Contributor

What

The on-chain half of the card-spend safety model. Two new Soroban contracts that together let the issuer adapter compute Spendable = on-chain balance − open liens − queued-but-unexecuted delay entries, so the same dollar can't back a card authorization and an outbound withdrawal at the same time.

delay-module — queue → cooldown → execution window (STE-40, #11)

A user-initiated operation (outbound transfer or config change) never executes immediately: it is queued, becomes executable only after a cooldown (180 s on this instance), and is void after the execution window (1800 s). The cooldown is exactly the time the adapter has to observe a pending withdrawal before money can leave.

  • No admin role at all — nobody, including Bleu, can cancel or execute a user's queued entry; cancel/execute are bound to the queuing user's require_auth, and expire is a permissionless reaper. This is a deliberate exception to the access-control pattern used by the other contracts, and it's verified by test.
  • Cooldown/expiration are per-instance constructor params (ADR-0001 defaults), never hardcoded; constructor rejects an empty execution window.
  • Every transition emits an event carrying (id, user) topics — the events are the interface the indexer (STE-43) and issuer adapter (STE-44) compute Spendable from, not telemetry. entry_queued carries executable_at/expires_at so liveness needs no contract read; expiry is passive (the indexer drops entries by expires_at, never by waiting for entry_expired).
  • A queued ConfigChange counts as a full-balance withdrawal (ADR-0001 decision chore(deploy): record 2026-06-05 redeployment + fail loudly on demo invoke errors #8), so queue rejects a nonzero amount on config changes.

Architecture doc: docs/architecture/delay-module.md.

card-collateral — lien-aware production vault (STE-39, #10)

Extends the testnet PoC state machine so every lock has an owner and the contract maintains a per-user lien aggregate: reserve(user, auth_id, amount) records the lien without moving tokens; settle/release reduce it; get_locked_total(user) exposes the aggregate as a single O(1) read so the smart account and issuer adapter never enumerate per-auth locks.

  • Invariant: locked_total(user) = Σ max(0, locked − settled) over the user's open locks, maintained incrementally; a breached lock contributes 0, never a negative.
  • Shortfall semantics unchanged from the PoC (over-clearing emits the shortfall event; capping the debit is downstream adapter work). The PoC contract is untouched and stays off the audit path.
  • All events now carry an owner topic so the indexer can attribute lien changes per user.
  • Composes the same OZ stellar-contracts =0.7.1 blocks as the PoC: pausable (reserve gated; settle/release stay available to wind down) and access_control.

Deployment & SDK

Both contracts are live on testnet (deployments/testnet.json): delay-module at CAA47ICI…R72S, card-collateral at CA3GIFBK…6YMK. TypeScript bindings are generated and wired into the SDK (corridorClients now exposes cardCollateral and delayModule), and scripts/deploy_testnet.sh deploys both (delay params overridable via DELAY_COOLDOWN_SECS/DELAY_EXPIRATION_SECS).

docs/DEMO.md has live captured runs for both: queue → cooldown rejection → execute, the user-only cancel path, and the collateral reserve → settle → release lifecycle including a breaching settle.

Tests

  • cargo test -p bleu-delay-module — full lifecycle, window boundary conditions (half-open [t+cooldown, t+expiration)), user-only cancel/execute, permissionless expire.
  • cargo test -p bleu-card-collateral — 11 unit tests: lien accounting across mixed lock states, per-user isolation, shortfall with the lien floored at 0, double-reserve rejection, pausable circuit breaker.

Out of scope (downstream tickets)

The composing smart account (STE-41), the indexer's Spendable view (STE-43), and the issuer adapter that consumes it (STE-44).

* feat(delay): delay-module policy contract — queue, cooldown, expiration

Standalone Soroban policy contract per ADR-0001: user operations queue,
become executable inside [cooldown, expiration) (constructor params;
defaults 180/1800 s passed at deploy, never hardcoded), and are void past
the window. No admin role exists — cancel/execute are bound to the queuing
user's require_auth, verified by test. All four transitions (queued,
executed, cancelled, expired) emit events carrying user/kind/amount for
the indexer's Spendable computation; expiry is passive, so entry_expired
is emitted by a permissionless expire reaper.

10 unit tests: tight boundaries at both window edges, no-admin auth
checks, amount validation, empty-window constructor rejection.

* feat(delay): testnet deploy, TypeScript bindings, live queue→wait→execute demo

Deployed delay-module to testnet at CAA47ICIUOVQEHZFIUJFBJYCWF6WJBLIYRRN4AJVHT5O7LNZ2LN7R72S
with the ADR-0001 defaults (cooldown 180 s, expiration 1800 s) and wasm
provenance metadata pointing at the commit that introduced the contract.
Bindings generated into sdk/typescript/src/generated/delay-module and wired
into corridorClients as delayModule.

Live demo captured in docs/DEMO.md §4, all user-signed (no admin exists):
queue (entry_queued) → execute during cooldown rejected with Error #4 →
execute after 3 min succeeds (entry_executed), plus the cancel path
(entry_cancelled) on a queued config change.
* feat(contracts): lien-aware card-collateral contract for the production vault

Extends the PoC reserve/settle/release state machine with per-lock
ownership and a per-user lien aggregate. get_locked_total(user) is a
single O(1) read maintained at reserve, settle, and release; a breached
lock contributes 0. Shortfall semantics match the PoC; lifecycle events
carry the owner as a topic for per-user indexing. PoC untouched.

STE-39

* feat(sdk): deploy card-collateral to testnet, generate + wire TS bindings

Deployed at CA3GIFBKAKCNLNK4EMNPKUCAYKGHLGW5Y6T37SSY464DRYDFJJHE6YMK
(admin recorded in deployments/testnet.json; differs from the May 30
deployer key, noted under "admins"). Lifecycle exercised live via CLI:
reserve -> settle -> release with get_locked_total tracking the lien,
plus a breaching settle emitting the shortfall event.

STE-39
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.

1 participant