Skip to content

fix(contracts,indexer): close remaining audit findings + subscription… - #100

Merged
Zeegaths merged 3 commits into
mainfrom
fix/audit-remediation-retry-backoff
Sep 7, 2026
Merged

Zeegaths merged 3 commits into
mainfrom
fix/audit-remediation-retry-backoff

Conversation

@Zeegaths

Copy link
Copy Markdown
Contributor

… retry backoff

Buantum audit (2026-07-10) remediation. Eight of the twelve findings were already fixed in the tree; this closes the remaining four and the off-chain gaps the audit's scope (A/05) excluded.

Contracts (StrimzSubscriptions is immutable — requires redeploy):

  • M-01: burn chargeAttemptId immediately before _settleCharge instead of on entry. Only Charged and TransferFailed consume an id now; NotDue, InsufficientFunds, RevokedApproval and MerchantInactive stay retryable on the same deterministic id. Previously eight failed sweeps (~2h) exhausted every id for a period and stranded billing for the rest of the grace window.
  • M-02: reject a past startAt in both enrolment paths, placed before permit() so a rejected enrolment does not burn the payer's token nonce.
  • I-01: cancelling an already-cancelled subscription reverts.
  • I-02: remove the dead JobStatus.Approved variant (pre-launch, no rows).
  • Declare activate() on IStrimzAgentRegistry — implemented but unreachable through the interface (L-04 gap).

Off-chain alignment:

  • Regenerate the escrow ABI. JobResolved/JobReclaimed/JobRefunded were absent from the indexer's topic filter so those logs were never fetched, leaving disputed and timed-out jobs parked in a non-terminal status. JobDisputed had also gained an indexed by param, changing its topic0 — disputes were never indexed either.
  • AgentJobStatus gains funded/resolved/reclaimed. accepted stays: it is off-chain only and guards approveJob against double-funding.
  • Split SubscriptionChargeOutcome per contract outcome; six outcomes previously collapsed into "skipped", hiding retryable vs terminal.

Retry backoff (scheduler + indexer):

  • Subscription gains retryCount and nextRetryAt; the sweeper honours nextRetryAt. Escalating schedule 15m/1h/3h/6h/12h/24h lives in the SQL that increments the counter so the two stay atomic. ~6 attempts per 48h grace window instead of ~192.

Incidental fixes found in the path of the above:

  • A successful charge never reset Subscription.status, so a recovered payer stayed at_risk and was flipped to lapsed by the grace cron.
  • The monthly commerce spend cap counted timeout-reclaimed (fully refunded) jobs against the merchant.
  • at_risk fired on any skip outcome, downgrading healthy payers on a NotDue.
  • subscription.charge_failed webhooked merchants on non-failures.

forge test: 126 passed (was 118, +8 regression tests). go build/test clean.

Zeegaths and others added 2 commits August 21, 2026 02:55
… retry backoff

Buantum audit (2026-07-10) remediation. Eight of the twelve findings were
already fixed in the tree; this closes the remaining four and the off-chain
gaps the audit's scope (A/05) excluded.

Contracts (StrimzSubscriptions is immutable — requires redeploy):
- M-01: burn chargeAttemptId immediately before _settleCharge instead of on
  entry. Only Charged and TransferFailed consume an id now; NotDue,
  InsufficientFunds, RevokedApproval and MerchantInactive stay retryable on
  the same deterministic id. Previously eight failed sweeps (~2h) exhausted
  every id for a period and stranded billing for the rest of the grace window.
- M-02: reject a past startAt in both enrolment paths, placed before permit()
  so a rejected enrolment does not burn the payer's token nonce.
- I-01: cancelling an already-cancelled subscription reverts.
- I-02: remove the dead JobStatus.Approved variant (pre-launch, no rows).
- Declare activate() on IStrimzAgentRegistry — implemented but unreachable
  through the interface (L-04 gap).

Off-chain alignment:
- Regenerate the escrow ABI. JobResolved/JobReclaimed/JobRefunded were absent
  from the indexer's topic filter so those logs were never fetched, leaving
  disputed and timed-out jobs parked in a non-terminal status. JobDisputed
  had also gained an indexed `by` param, changing its topic0 — disputes were
  never indexed either.
- AgentJobStatus gains funded/resolved/reclaimed. `accepted` stays: it is
  off-chain only and guards approveJob against double-funding.
- Split SubscriptionChargeOutcome per contract outcome; six outcomes
  previously collapsed into "skipped", hiding retryable vs terminal.

Retry backoff (scheduler + indexer):
- Subscription gains retryCount and nextRetryAt; the sweeper honours
  nextRetryAt. Escalating schedule 15m/1h/3h/6h/12h/24h lives in the SQL that
  increments the counter so the two stay atomic. ~6 attempts per 48h grace
  window instead of ~192.

Incidental fixes found in the path of the above:
- A successful charge never reset Subscription.status, so a recovered payer
  stayed at_risk and was flipped to lapsed by the grace cron.
- The monthly commerce spend cap counted timeout-reclaimed (fully refunded)
  jobs against the merchant.
- at_risk fired on any skip outcome, downgrading healthy payers on a NotDue.
- subscription.charge_failed webhooked merchants on non-failures.

forge test: 126 passed (was 118, +8 regression tests). go build/test clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxLmrcAgiH9TVVcZjEZ46J
`AgentJobStatus` gained funded/resolved/reclaimed so the escrow's
dispute-resolution and timeout-reclaim exits are representable. The job
detail page keys a Record on that union, so `next build` failed on the
three missing keys.

resolved and reclaimed are terminal but not clean successes — an
arbitrated split and an escrow return — so both read neutral rather than
positive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxLmrcAgiH9TVVcZjEZ46J
@dalmasonto

Copy link
Copy Markdown

Review: audit remediation looks solid

I checked this against the full Buantum report. All twelve findings are closed in the branch. The four High findings are genuinely fixed, not patched over.

H-01 and H-03 are done well. Each binds every fund-routing parameter into a dedicated EIP-712 intent. The intent is verified before the token call. So a relayer cannot redirect funds or rewrite subscription terms.

The M-01 fix is the careful one. It is correct. The attempt id is now burned only when money is about to move. Early and skipped sweeps stay retryable. The anti-double-charge guarantee still holds.

The off-chain alignment is clean. The enum change is safe. The indexer keys off named events, not numeric enum values. I ran the tests locally. 126 passed, zero failed.

One thing to fix before merge.

Look at apps/indexer/internal/store/projections.go. The subscription.charge_failed emit hardcodes the mode to "test". The success path uses the subscription's real mode. The mode is already read into a local variable a few lines above. So this is a one line change. Pass that variable instead.

Right now a live subscription's real payment failure gets tagged as test. It never reaches live merchant endpoints. That is the exact webhook this PR is trying to make reliable. Please add a live-mode assertion to the charge-failed webhook test too.

Two smaller notes. Fine to leave as is.

The spend cap excludes disputed jobs while escrow is still held. A merchant could briefly queue extra spend during a dispute. It re-counts on resolution.

The retry backoff depends on the indexer projecting the skip before the next tick. The on-chain idempotency and charge lock keep that bounded.

A rollout reminder, not a code issue.

The Payments and Subscriptions fixes live in immutable contracts. They need a fresh redeploy. Re-point every consumer after.

If the escrow proxy is upgraded in place, the removed enum variant shifts the later indices. That would misread any in-flight job status. Prefer a clean deploy. Or drain in-flight jobs first.

Nice work overall.

… real mode

The charge-failed outbox emit hardcoded mode to test. The webhook
dispatcher filters merchantWebhookEndpoint on ev.mode, so a live
subscription's payment failure matched zero live endpoints and was
dropped with no error, no retry, and no dead letter — the merchant
never learned the charge failed. Pass the  already read from
the Subscription lookup.

Also close two gaps in the e2e coverage that let this through:

- The lifecycle test skipped with IsPaymentFailure unset, so the
  webhook branch was never exercised. It now sets the flag and
  asserts the emitted event carries mode=live.
- Add TestE2E_SubscriptionChargeSkip_NonPaymentFailureEmitsNoWebhook
  for the other half of the M-01 gate: a not_due skip writes the
  audit row but emits no webhook and leaves the payer out of at_risk.
@Zeegaths
Zeegaths merged commit 729e4d7 into main Sep 7, 2026
3 checks passed
@Zeegaths
Zeegaths deleted the fix/audit-remediation-retry-backoff branch September 7, 2026 10: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.

2 participants