Skip to content

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

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

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

Conversation

@CoderZhi

@CoderZhi CoderZhi commented May 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

This branch adds two related witness-side safety features that share the same Lark-card / approval-server infrastructure:

1. Pre-sign approval guard (USD-based caps)

  • 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.

2. Stale-height alert + mute (source data deleted)

When the relayer reports a stale block height the witness can no longer fetch (source chain/API pruned that history), the witness used to abort ProcessStales on the first failure — blocking every later stale height behind it and re-logging every tick.

  • ProcessStales now skips-and-continues past un-fetchable heights, and posts a Lark warning card with a Mute button (cashier + height). Muting records the height in-memory in the per-cashier ApprovalGuard, so it's skipped on later ticks and stops alerting. Falls back to a deduplicated text alert when no guard/card webhook is configured.
  • Alerts fire only for heights at/below the witness tip; a height above the tip means the witness is still catching up (not missing data) and is skipped silently to avoid false "data deleted" pages on restart.
  • Current vs. previous-cashier heights are attributed correctly in the card; the Mute button still routes back to the posting guard via LarkStaleWarning.GuardKey.
  • Admins resolve the transfer out-of-band with sign-witness / submit-witness (no API fetch needed). New runbook: witness-service/docs/missing-transaction-runbook.md (find → sign → submit → mute).

Changes

  • witness/approval.go — guard core, Check, RequestApproval, Approve/Reject, nonce/replay protection; stale-height mute state + IsHeightMuted / NotifyStaleFetchFailure / Mute (dedup map pruned on insert)
  • witness/approval_server.go — HTTP callback handler with HMAC-SHA256 signature verify and replay protection; mute action
  • util/lark_card.go — interactive approval + stale-warning card senders (shared postLarkInteractiveCard poster), signature verifier, uint64FromValue parse helper
  • util/coingecko.go — cached CoinGecko price source with refresh loop and max-age
  • witness/recorder.goSignedAmountSince, MarkTransferAwaitingApproval, ApproveTransfer, RejectTransfer
  • witness/types.goTransferAwaitingApproval, TransferApproved, TransferRejected statuses
  • witness/tokencashierbase.go — integrate guard before signing; rework ProcessStales (skip muted, skip-and-continue, tip-aware alerting)
  • cmd/witness/main.go — wire config, price feed, and approval server
  • docs/missing-transaction-runbook.md — admin runbook for missing/deleted source transactions

Test plan

  • go build ./witness-service/...
  • go test ./witness-service/... (all pass; includes guard mute/dedup, Lark card decode/send, approval-server mute path)
  • 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
  • Manual: point a witness at a stale height with no node data, confirm one Lark warning card, click Mute, confirm it no longer alerts next tick

🤖 Generated with Claude Code

CoderZhi and others added 6 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>
When the relayer reports a stale block height that the witness can no longer
fetch (the source chain/API pruned that history), ProcessStales used to return
on the first failing height, blocking every later stale height behind it and
re-logging the failure every tick.

Now ProcessStales skips-and-continues past un-fetchable heights and warns the
admin via a Lark card carrying the cashier + height with a single Mute button.
Clicking Mute records the (cashier, height) in the per-cashier ApprovalGuard
(in-memory) so the height is skipped on subsequent ticks and stops alerting.
When no guard/Lark is configured, it degrades to a deduplicated text alert.

The admin resolves the transfer out-of-band with sign-witness/submit-witness
(no API fetch needed); a runbook documents find -> sign -> submit -> mute.

- approval.go: mutedHeights/staleAlert state; IsHeightMuted,
  NotifyStaleFetchFailure (per-height dedup, muted suppression, card->text
  fallback), Mute (idempotent)
- util/lark_card.go: LarkStaleWarning + SendLarkStaleWarningCard; Height on
  LarkCallback and height parsing in DecodeLarkCardCallback
- approval_server.go: "mute" callback action
- tokencashierbase.go: ProcessStales skip-muted + skip-and-continue
- docs/missing-transaction-runbook.md + README link
- tests: guard mute/dedup, card decode/send, approval-server mute path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ProcessStales: only alert on fetch failures at/below the witness tip;
  heights above tip mean the witness is catching up (not missing data), so
  skip them silently to avoid false "source data deleted" pages on restart.
  Documented the stuck-tip edge case.
- ProcessStales: process current vs previous cashier heights separately and
  pass the owning cashier to the alert so the card attributes the right
  contract; the Mute button still routes back to the posting guard via a new
  LarkStaleWarning.GuardKey field.
- ApprovalGuard.NotifyStaleFetchFailure: prune expired staleAlert entries on
  insert (mirrors seenNonces) so the dedup map can't grow unbounded.
- util/lark_card.go: extract postLarkInteractiveCard (webhook check + JSON
  envelope + POST + status handling) shared by both card senders; extract
  uint64FromValue shared by the tidx/height parse paths.
- runbook: note Mute requires the approval card; clarify above-tip skip.
- tests: GuardKey routing.

Co-Authored-By: Claude Opus 4.8 (1M context) <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