Skip to content

witness: pre-sign approval guard with USD-based caps#316

Open
CoderZhi wants to merge 4 commits into
masterfrom
witness-approval-guard
Open

witness: pre-sign approval guard with USD-based caps#316
CoderZhi wants to merge 4 commits into
masterfrom
witness-approval-guard

Conversation

@CoderZhi

Copy link
Copy Markdown
Collaborator

Summary

  • Adds an ApprovalGuard that gates witness signing on a per-cashier rolling-window USD cap and a per-transfer USD cap, with Lark-based admin approve/reject for transfers above the single-tx cap.
  • Disabled by default; opt-in via new approval / priceFeed config blocks and per-cashier windowValueLimit / singleTxValueLimit. Existing chain configs are unchanged.
  • Fails closed: if the guard cannot evaluate a transfer (missing/stale price, missing token metadata, transient errors) signing is paused for that transfer rather than allowed through. Dedup'd Lark alerts (10 min) keep noise bounded.
  • One-shot decision semantics: in-memory decision lock when pending state is present (first-responder wins), with a DB-only fallback after a witness restart so admin clicks still work; UPDATE ... WHERE status='approval' provides atomicity. cb.Cashier is used only for routing; SQL WHERE always uses the guard's bound cashier key.

Changes

  • witness/approval.go — guard core, Check, RequestApproval, Approve/Reject, nonce/replay protection
  • witness/approval_server.go — HTTP callback handler with HMAC-SHA256 signature verify and replay protection
  • util/lark_card.go — interactive approval card sender + signature verifier
  • util/coingecko.go — cached CoinGecko price source with refresh loop and max-age
  • witness/recorder.go — new SignedAmountSince, MarkTransferAwaitingApproval, ApproveTransferToReady, RejectTransfer
  • witness/types.goTransferAwaitingApproval, TransferRejected statuses
  • witness/tokencashierbase.go — integrate guard before signing
  • cmd/witness/main.go — wire config, price feed, and approval server

Test plan

  • go test ./witness-service/... (72 tests across 18 packages pass)
  • go build ./witness-service/...
  • Manual: enable on a non-prod cashier, set a low single-tx limit, confirm Lark card delivery and approve/reject flow
  • Manual: restart witness while a transfer is in approval state, verify admin click still resolves it via DB-fallback path
  • Manual: simulate stale price feed, verify signing pauses with deduplicated alerts and resumes once price refreshes

🤖 Generated with Claude Code

CoderZhi and others added 4 commits May 25, 2026 23:50
…SD caps

Introduces an ApprovalGuard that gates witness signing on configurable
USD limits, with Lark-based admin approval for transfers above the
single-tx cap. Disabled by default; opt-in per cashier via config.

- util/coingecko: cached price source for USD conversion
- util/lark_card: interactive approval card sender + HMAC signature verify
- witness/approval: guard core, decision lock with DB-fallback after restart
- witness/approval_server: HTTP callback handler with replay protection
- witness/recorder: SignedAmountSince, MarkTransferAwaitingApproval,
  ApproveTransferToReady, RejectTransfer
- types: new TransferAwaitingApproval and TransferRejected statuses
- tokencashierbase: integrate guard before signing; fail closed on
  evaluation errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Make the per-token coingeckoID config field optional. At witness startup,
unset ids are resolved via GET /coins/{platform}/contract/{token1}. Bridged
or otherwise unindexed tokens can still be handled by setting coingeckoID
explicitly as an override. Existing configs are unchanged.

- util/coingecko: ResolveIDByContract + ErrCoinGeckoIDNotFound sentinel
- cmd/witness/main.go:
  - chain → CoinGecko platform map + native-gas id map (for zero-addr token)
  - coingeckoResolver (in-process cache, one HTTP call per unique address)
  - tokenMetasFor* consult resolver when CoinGeckoID is empty
  - newPriceFeed + startPriceFeedRunner split so cashier wiring can run
    between cache construction and runner start (price feed now uses the
    union of explicit + auto-resolved ids)
  - buildApprovalGuard error message clarifies that coingeckoID is now
    an override, not a requirement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the admin-approval flow flipped a row from `approval` straight
back to `ready`, so the next signing tick re-ran guard.Check and the
single-tx limit pushed the row back into `approval` again — the admin
decision was silently undone.

Introduce TransferApproved (8 chars, fits varchar(10)). ApproveTransfer
now sets `approved`; the cashier loop skips guard.Check for that status
and treats it like `ready` for signing/confirm. Rolling-window total
keeps counting only post-sign statuses (pending/confirmed/settled), so
the admin override does not leak into the window cap.

Also drop the lingering MFA wording from comments in types.go and
solrecorder.go — the actual flow is Lark interactive cards, not MFA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end coverage for the approval flow: tick 1 holds an over-limit
transfer; flipping the status to `approved` (as Recorder.ApproveTransfer
does in SQL) lets tick 2 sign it without re-running guard.Check. Guards
against future regressions in the approval ↔ approved transition path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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