StrimzStrimz
Concepts

Safety nets

A handful of defence-in-depth mechanisms that have saved us from real bugs. Each one was added in response to something specific; each one stays out of the happy path.

Safety nets aren't optimizations. They're the layers you build because you've seen the thing they prevent. Every mechanism on this page exists because we hit a real edge case (or someone else's audit caught one for us).

The general principle: critical guarantees should be enforced by more than one layer. If your double-pay protection is one line of code, a single bug in that line wipes you out. If your double-pay protection is three independent layers. Indexer stamp, API safety net, hosted-page status check. A bug in one of them is recoverable.

Here's what's in production.

Three layers against double-pay

A payer hits "Pay," the page is slow to react, they click again. Without protection, two on-chain submissions go out and two settlements happen. Strimz prevents this three ways, each independent.

The first layer is in the indexer. Once PaymentSettled lands on chain and the indexer projects it, the session's onchainTxHash is stamped non-null. The submission API checks this column before kicking off a new broadcast. If there's already a tx hash, the API short-circuits and returns the existing confirmation view. The submission never reaches the relayer.

The second layer is the API safety net. Before broadcasting, the API runs alreadyPaidView(sessionId) against Postgres. This query looks for a session in submitted or confirmed state with a stamped tx hash. If it finds one, the API returns the existing confirmation directly. This catches the race where the indexer has projected the confirmation but the in-memory cache hasn't picked it up.

The third layer is on the hosted checkout page itself. The page polls GET /v1/payment-sessions/:id every two seconds while the user is on the page. The moment status flips to confirmed, the Sign button is replaced with a "Paid" state. Clicking does nothing.

In normal operation only the third layer matters. Most users would never click twice fast enough to escape the polling interval. But the first two layers are there for the edge cases: bots, network re-tries from the user's wallet software, that one customer whose ISP keeps dropping the request mid-flight. Any single layer would catch the duplicate in normal cases. All three together make double-pay structurally impossible.

Double-enrolment protection for subscriptions

Same problem, different resource. A payer signs the EIP-2612 permit, the page is slow, they sign again. Two subscriptions for the same plan.

The first layer is a unique column. Subscription.enrolmentTxHash is UNIQUE in Postgres. Two rows with the same enrolment tx can't exist; the database rejects the second insert with a constraint violation. The API surfaces this as a clean "already enrolled" error.

The second layer is the same kind of safety-net query as for payments. Before broadcasting permitAndCreateSubscription(...), the API runs alreadyEnrolledView(planId, payerAddress). If there's already an active subscription for the same plan and the same wallet, the API short-circuits and returns the existing subscription. The payer's wallet pops a second prompt, they sign it, but the second signature is never submitted on chain.

The merchant sees one Subscription row. The payer sees one charge. The contract sees one SubscriptionCreated event.

Stale charge-lock reclaim

This one came from a real incident. We were running scheduler v1 before we had robust crash handling; a scheduler replica caught a SIGKILL mid-batch (we were doing a rolling deploy and missed a graceful-shutdown signal). The scheduler had set chargeLock = true on twelve subscriptions and hadn't unset it. The next scheduler tick saw the rows as locked and skipped them. They sat locked indefinitely.

We caught it the next morning when one of those merchants emailed us asking why their subscriptions hadn't charged. Embarrassing. We added the fix the same day.

The fix is a TTL on the lock. The sweeper's WHERE clause:

WHERE (
  "chargeLock" = false
  OR "chargeLockAcquiredAt" < NOW() - INTERVAL '1 second' * $3
)

$3 is 600 seconds (10 minutes), fixed in code. The TTL is loose by design. A healthy charge job finishes in seconds and the worker releases the lock itself, on success or failure; 10 minutes is "the scheduler must have crashed" territory.

The mechanism is self-healing. A scheduler crash mid-batch becomes a 10-minute pause, not an indefinite stall. The next replica that picks up the row reclaims the lock atomically (the UPDATE ... WHERE chargeLock = false OR chargeLockAcquiredAt < NOW() - INTERVAL ... happens in one statement; row-level locking ensures only one replica wins the reclaim).

Callback URL allowlist

This one came from an audit reviewer. The original successUrl and cancelUrl fields were validated as URLs but didn't restrict the protocol. The reviewer pointed out that a malicious merchant could set successUrl: 'javascript:alert(1)' , and when the payer's wallet redirected to that URL, the JavaScript would run in the wallet's WebView context. Not catastrophic in most wallets but absolutely not okay.

The fix is a strict allowlist:

const callbackUrlSchema = z
  .string()
  .url()
  .refine((v) => v.startsWith('https://') || v.startsWith('http://'), {
    message: 'must be an http:// or https:// URL',
  })

The schema is applied at API validation. A successUrl or cancelUrl that isn't http:// or https:// is rejected at paymentSessions.create. It never reaches the database; it never reaches the redirect logic.

We allow http:// because some merchants use it in local dev. Production should always be https://; the warning shows up in the dashboard if you create a live-mode session with an http:// callback.

The race-condition projector fix

This one is the deepest in the stack. The indexer projects both SubscriptionCharged and SubscriptionChargeSkipped. The sweeper runs every 15 minutes, and the indexer can take a few seconds to project a successful charge. If a sweep fires in that window, the worker calls batchCharge again for a period that is already paid. The contract skips it with NotDue or Duplicate.

A naive projector would see that skip and downgrade a payer who did pay to at_risk.

The fix is to split skip outcomes in two. Only payment failures (InsufficientFunds, RevokedApproval, MerchantInactive, TransferFailed) move a subscription to at_risk, fire subscription.charge_failed and arm the retry backoff:

UPDATE "Subscription"
   SET status = 'at_risk'::"SubscriptionStatus",
       "retryCount" = "retryCount" + 1,
       "nextRetryAt" = ...
 WHERE id = $1
   AND status IN ('active'::"SubscriptionStatus", 'at_risk'::"SubscriptionStatus")

A NotDue, Duplicate, Ended or Unknown skip is still written as a SubscriptionCharge row for the audit trail, but the subscription's status and retry timer are left alone. A successful charge always resets the subscription to active and clears the retry state.

What they have in common

All five are silent during normal operation. None of them log anything in the happy path; none of them slow down a successful payment or a successful charge. They only do work when something unusual happens. A double-click, a crashed scheduler, a malicious URL, a network race. The cost of running them is essentially nothing.

The cost of not running them is one bad week per incident. We'd rather have the small ongoing cost than the rare big one.

On this page