Skip to content

fix(cards): harden spend refunds, token and destination checks - #63

Merged
Slokh merged 2 commits into
mainfrom
harden-card-spend-checks
Nov 14, 2026
Merged

Slokh merged 2 commits into
mainfrom
harden-card-spend-checks

Conversation

@Slokh

@Slokh Slokh commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

Hardens card spend settlement in CardSpender and Bridge's "TS" handler (SpenderTempoSpend). Existing programs and handlers are unchanged; this applies to new deployments.

  • TIP-20 only. CardSpender and the "TS" handler refuse a settlement token that is not a TIP-20 (UnsupportedToken), so no transfer callback, fee or rebase can upset the exact-credit checks. A vault's asset must equal the token, so this covers redemptions too.
  • Measured refunds. Unused EarnShares are refunded from the contract's measured share balance, not the vault's reported burn, in CardSpender direct public spends and in the "TS" handler. A vault that under-reports can no longer make either refund shares it held before.
  • Refunds must credit the payer. The "TS" handler checks that the payer's share balance rises by exactly the refund (RefundNotCredited), as CardSpender already does, so a receive policy cannot redirect a direct caller's refund to a guard.
  • Virtual destinations. The "TS" handler keeps authorizing only the literal destination on the program's allowlist, unlike TIP-403, which authorizes the master: a master may hand out its virtual addresses as separate deposit accounts, so allowing a master must not allow them. Documented in docs/cards/bridge/design.md, with a test that a virtual address of an allowed master is refused.
  • Vault allowlist. "UV" refuses the zero address (InvalidVault).
  • Docs. Clarifies CardSpend.fromVault (only public spends mix stablecoin and Earn), states that a Zone key's approve and requestWithdrawal go in one transaction for the exact amount, and lists the known ways an approved private spend can fail to settle under the threat model's accepted risks.

Changing SpenderTempoSpend and CardSpender moves their deterministic addresses (docs/cards/bridge/deploy.md).

Tests

New: direct clone refunds blocked by a receive policy (default and payer authority) revert atomically and succeed without one; refunds measured against a vault that under-reports, for the program and the clone; non-TIP-20 tokens refused; a virtual address of an allowed master refused; "UV" with the zero address. Mock tokens in existing hostile-token tests now stand in for TIP-20s so they still exercise the contracts' own checks.

  • forge fmt --check src test script, forge lint --deny warnings ..., forge build --sizes src
  • FOUNDRY_PROFILE=ci forge test: 674 passed, 0 failed, 50 skipped
  • script/rain/build-fixture.sh --check
  • pnpm --filter @tempoxyz/contracts test --run docs, vp fmt --check 'docs/**', git diff --check

@tempoxyz-bot

tempoxyz-bot commented Nov 14, 2026 •

Copy link
Copy Markdown

👁️ Cyclops Security Review

✅ Reviewed 250b1dd (latest) · Coverage: partial · ⚠️ 1 open finding · report
⚠️ Not checked: I could not read the pinned tempo-std source (rev 96f882963ce3b0e0abc7ec2b41d03d42457877bb) to confirm the StdPrecompiles.TIP20_FACTORY.isTIP20 interface and the precompile's behaviour for non-TIP-20 addresses.
⚠️ Not checked: The new TIP-20 eligibility guards depend on StdPrecompiles.TIP20_FACTORY.isTIP20. cards/lib is absent, and the supplied source capability does not expose tempo-std at 96f882963ce3b0e0abc7ec2b41d03d42457877bb or Solady at a5bb996e91aae5b0c06
ℹ️ Not verifiable from the source alone (needs running code or deployed state): No Foundry, integration, or native-precompile tests were executed. Required Solidity libraries are absent, and installing them or producing normal build artifacts would modify the read-only checkout.

# Severity Finding Introduced Status
1 ⚪ Informational CardSpender's TIP-20 settlement gate has no test that fails when removed 8270b86 🟠 Open

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push and didn't find new bugs. Commit 250b1dd fixes both earlier virtual-destination comments: the "TS" handler now authorizes only the literal destination, which again matches BridgeProvider.isAuthorizedDestination and preflight. The PR description's "Virtual destinations" bullet still describes the removed master-based rule, so it needs updating. This changes settlement and refund logic in both spenders and moves their deterministic addresses, so a human look would still be worthwhile.

What was reviewed:

  • The measured-share refund in CardSpender._redeemPublic and SpenderTempoSpend. Over-burning reverts against the starting balance. A vault that over-refunds makes required - unused underflow, so the spend reverts.
  • The new _refund exact-credit check, the isTIP20 gate in both _check paths, and the zero-vault rejection in "UV".
  • The docs on literal-only destinations and the threat-model risks, checked against the code.
Extended reasoning...

The change touches token-settlement code in cards/src/core/CardSpender.sol and cards/src/providers/bridge/SpenderTempoSpend.sol, plus tests and docs. It changes how EarnShare refunds are calculated and checked, which token types are accepted, and vault allowlisting. That is payment- and authorization-sensitive surface. The new commit reverts the master-based destination authorization flagged earlier, and no new issue was found. It is still left for a human because it is security-sensitive settlement logic whose deterministic deployment addresses change.

Comment on lines +229 to +231
// Only TIP-20s settle: no transfer callbacks, fees or rebases to upset the exact-credit checks. A vault's
// asset must equal `s.token`, so this covers redemptions too.
if (!StdPrecompiles.TIP20_FACTORY.isTIP20(s.token)) revert UnsupportedToken(s.token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational: CardSpender's TIP-20 settlement gate has no test that fails when removed

The change adds an UnsupportedToken check to CardSpender._check, but no test asserts it: every core and Bridge program test that uses a non-TIP-20 token mocks the factory's isTIP20 to true. With the check deleted, the full cards suite still reports 675 passed, 0 failed, 50 skipped. First reported at 8270b86; recheck at this head: still present.

Valid? React with 👍 / 👎.

@Slokh
Slokh merged commit bc8b114 into main Nov 14, 2026
36 checks passed
@Slokh
Slokh deleted the harden-card-spend-checks branch November 14, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants