A full-surface review of the smart contract, SDK, bridge/gasless integration, and the two demo UIs, covering the common failure modes of blockchain apps (bridge/transfer/signature/wallet handling) and of AI-generated code. Each finding is marked Fixed, Hardened, Accepted, or Documented.
The two apps are a testnet demo. To be a self-contained playground with no external wallet, they sign with throwaway Arc-testnet demo keys. Those keys are disposable and hold only test USDC. Nothing in this repo should ever hold a real-value or mainnet key. Production integrators must run all signing server-side.
No critical or high issues. Verified clean: reentrancy (all mutators are
nonReentrant with strict CEI; the verifier call is a STATICCALL), integer
bounds, access control, duplicate/colliding ids, zero-amount / zero-address /
self-transfer guards, SafeERC20 on every movement, and no admin / upgrade /
selfdestruct surface. Funds are always recoverable by the sender.
- Claim-code reveal front-running: safe by design.
claim()submits the plaintext code, so it is visible in the mempool, but the payout target is read from storage (t.to), nevermsg.sender. A front-runner who replays a revealed proof merely settles the transfer for its intended recipient, which is exactly the gasless-relayer behavior the design wants. Funds cannot be redirected. - Accepted: griefing lockout. Anyone can burn a transfer's 5 wrong-code
attempts and move it to
LOCKED, blocking the recipient's claim (a liveness/DoS griefing vector). No funds are lost: the sender can alwayscanceland re-send. This is a deliberate tradeoff (counting attempts only for the recipient would let an attacker grind the 20-bit code for free from throwaway addresses). Documented as a known limit for testnet. - Documented:
receive()accepts native value that is unrecoverable. Stray native/USDC sent directly to the contract is stranded (no sweep, no admin). It does not affect per-transfer accounting. Consider droppingreceive()on a redeploy.
- Verified secure: claim secret + commitment. One 80-bit secret (16 Crockford
base32 characters) from a CSPRNG (
crypto.getRandomValues) with rejection sampling, so no modulo bias. The salt is derived from it (keccak256("ctrl-arcz:salt:v1"‖secret)), so the on-chain commitmentkeccak256(salt‖code)has 80 bits of preimage entropy. That is the number that matters:claimpays the recipient recorded on-chain, and in a poisoning attack that recipient IS the attacker, so they holdclaimHashand can grind it offline without limit. 80 bits survives that; the 20 bits of a 6-digit code would not. Delivery is part of the security property, not a UX detail: the secret must reach a HUMAN through a channel the attacker is not in. Any delivery keyed to the address (on-chain ciphertext, a backend, a push, a link the app fetches) also reaches the attacker, because the address is theirs. See the note at the end of this file: this supersedes the earlier split-secret design and the findings written against it. - Verified secure: Permit2 signing. Correct EIP-712 domain (
Permit2, chainId, verifyingContract, no version field),spenderbound in the typed data, boundeddeadline, full-entropy unordered nonce → no cross-chain or same-chain replay. - Fixed: claim-receipt event binding.
interpretClaimReceiptnow matches events by emitting contract address and exacttransferId, not event name alone. In a batched/ERC-4337 receipt (the gasless path), an unrelated or attacker-plantedTransferClaimedcan no longer decide a victim transfer's outcome.TransferCreatedselection is likewise bound to the contract address. - Fixed: firewall fails closed. If the sender's payment history can't be fetched the lookalike rule can't run; the report now blocks an unverified target instead of downgrading to a click-through warning. A firewall that waves traffic through when its data source is down is worse than none. A verified/known-good recipient still only warns; a non-history source outage still only warns.
- Fixed:
RecipientVerifiedread failure is now recorded as incomplete data (never silently "safe"). - Hardened: send-path guards.
sendProtected*reject a zero/invalid recipient and non-positive amount before touching the chain. - Fixed: firewall is on by default.
sendProtected/sendProtectedWithPermitnow run the poisoningcheck()themselves before submitting, throwingRiskBlockedErroron ablockresult, so no funds move. Previously the scan was caller-invoked, so an integrator who calledsendProtecteddirectly got no firewall at all ("install the SDK, then remember to also call the firewall"). Verified live on Arc Testnet: a crafted lookalike passed straight tosendProtected, with nocheck()call of its own, threwRiskBlockedErrorand the sender's balance did not move. Hard blocks cannot be waved through. - One warning policy, not two. The guard runs the caller's
IntegratorConfigthrough the sameshouldBlockSendthe UI uses, so a config that saysonWarning: 'block'cannot mean one thing on the pre-send screen and another inside the SDK. An earlier revision had a second, separateonWarningoption on the send call, which silently ignored the config's own policy. RiskBlockedErrorcarries the wholeRiskReport, not just the message strings, so a caller that catches it can render the same explanation (rule codes,lookalikeOf,complete) instead of a flattened line of text.- A reused report must be fresh and about the right pair. Callers that already
ran
check()pass the report throughreportrather than disabling the guard. It is honoured only when it matches the same sender and target and is younger thanMAX_REPORT_AGE_MS(2 minutes); otherwise the guard re-scans. A stale or mismatched report is not evidence: a bait transfer could have landed since, and a clean report for address A must never wave through a send to poisoned address B. - Accepted:
skipRiskCheckexists. It removes the poisoning defense from the send path entirely. It is documented as a last resort, and the demo no longer uses it: the reference integration runs the real guarded code path. - Known dependency. The guard reads ArcScan. If the indexer is unreachable, a
send to an address the sender has never paid will throw, because a lookalike
cannot be ruled out and the firewall fails closed. That is the intended behaviour
of a firewall, but it makes
sendProtecteddepend on an indexer being up. Integrators can supply their ownIDataProvider, or anevaluateRiskreport built from their own data.
- Fixed: relayer/Circle keys removed from the browser. Gasless claims are now
signed server-side at
/api/gasless-claim(receivervite.config.ts→@ctrl-arcz/demo-kit/gasless); the browser only posts{ transferId, code, salt }.VITE_RELAYER_PKandVITE_CLIENT_KEYare no longer referenced in any client code and are verified absent from the production bundle (grep ofdist/returns none). The bridge was likewise server-side (/api/bridge) when this was written; it has since moved into the user's own wallet and those routes are deleted, so the tab gates on a non-secretVITE_BRIDGE_ENABLEDflag and there is no key behind it. - Mitigated: remaining
VITE_DEMO_PK. The demo's "connect wallet" is a headless test signer standing in for MetaMask (the user's own wallet), so it is read in dev only; it is not a server secret. A build guard in both apps refuses any production build that would inline aVITE_*_PK/VITE_CLIENT_KEYunless the operator setsVITE_ALLOW_DEMO_KEYS=1, turning "never in production" from a comment into enforcement. Always use throwaway testnet keys and rotate any that has been in a build. - Hardened:
/api/bridge. Now validatesfrom/toagainst a chain allowlist, enforces a positive amount with a hard cap, rejects cross-origin requests, caps the request body size, and returns generic errors (detail logged server-side). Still dev-only; never expose the dev server with--host. - Verified clean. Gasless userOp targets the fixed contract with SDK-encoded
calldata and pays the recorded recipient (a malicious relayer/paymaster gains
nothing); the test provider only installs when no real wallet is present and never
auto-approves;
.env/.env.localare gitignored (only placeholder.env.exampleis committed).
- Verified clean: no XSS via
dangerouslySetInnerHTML. The only sink (chain logos) is fed strictly by a compile-timeimport.meta.globover repo-committed SVG files; no user/network/localStorage string reaches it. All 11 committed SVGs were inspected: no<script>,on*,javascript:,<foreignObject>, or external refs. Noeval/innerHTML/document.writeanywhere. - Fixed: stored link injection. Explorer URLs read back from localStorage are
now scheme-checked (https only) before rendering as an
<a href>, so a tampered history entry cannot smuggle ajavascript:/data:link. - Verified clean. No part of the claim secret is ever read from a URL (links leak
into history, Referer headers and chat previews); the only query param left is a
non-secret
tidpointer, sanitized before use and never rendered as HTML; alltarget="_blank"links carryrel="noreferrer"; no open-redirect; SDK-built explorer links are fixed-scheme. - Fixed: a redefined
BigInt.prototypechose what we sent to Circle. Gateway request bodies were built with aJSON.stringifyreplacer testingtypeof v === 'bigint'.toJSONruns before the replacer, so anything definingBigInt.prototype.toJSONdecided the wire format and the replacer was handed a string it had no reason to touch. Observed in a real browser: an extension returning${this}nsent"value":"1000000n", and Circle refused every estimate and every transfer. No funds were at risk, because the burn intent is signed over the real bigints, so a rewritten body cannot make a payer overpay; it can only be rejected. The cost was denial of function, permanent in that browser, with a message that blamed Circle. Amounts are now converted beforeJSON.stringifyis given anything to convert, using a template literal rather than the redefinable.toString(). Nothing in this repo or its dependencies patches that prototype, and that is exactly the point: a page does not get to choose what else runs on it, so anything carrying an amount has to survive its neighbours. - Documented: address truncation in list rows. Counterparty/recipient rows show
0x1234…abcd, the same ambiguous form poisoning exploits. The send flow is guarded by the risk firewall; consider full-address-on-hover in history/pending lists.
- Rotate every key that has ever been in a build; never use real-value keys.
- The only server-signed path left is the gasless claim, and it can settle to nobody but the recipient already recorded on chain. Keep it that way; the only key still reaching the browser is the dev-only test-wallet stand-in.
- Set
onWarning: 'block'on yourIntegratorConfigand pass it tosendProtectedif your users must be hard-stopped on any doubt, not just a hard block. - Redeploy the contract if you want to remove
receive()or change the lockout griefing tradeoff.
A second, adversarial audit covering the redesigned payer-side shield, the new
standalone backend (apps/api, public at api.ctrlarcz.xyz), and the Expo mobile
app (apps/mobile). Three parallel reviews; findings below with status
Fixed / Mitigated / Documented.
- CRITICAL. CREATE2 salt did not commit to the policy → front-run init theft.
The salt bound only
ownerHash + userSalt, so an attacker could occupy the payer's predicted address with a substituted policy (target,cosigner, cap) and steal funds the payer then deposited;createEphemeralalso ignored the deploy receipt. Fixed: the salt now folds inkeccak256(InitParams)so a different policy maps to a different address, andcreateEphemeralreads the deployed policy back and refuses to fund a mismatch. Factory redeployed to Arc testnet; contract + unit + anvil + real-testnet e2e pass. - MEDIUM (DoS):
sweepToVaultpreimage was not a real capability. The vault is the public funding source, so any observer could front-run a pending pay with a sweep and permanently grief it. Fixed: only the vault (msg.sender == vault) may sweep pre-expiry;sweepExpiredkeeps the preimage-gated liveness hatch. - LOW. Co-signer trusts the client-supplied
owner(firewall scope only; funds stay locked to the on-chain target). Documented: bindownerto the authenticated session in production. - LOW. Co-signer does not re-validate
perPullMax/intervalfor PULL (the contract enforces both). Documented: add to the pre-sign checks for defense-in-depth. - Verified hardened: replay (cross-account/chain/nonce/action all fail),
signature malleability, CEI/reentrancy, the closed fund-exit set (locked target
or committed vault only), one-time
init, non-hijackable clone implementation, and fail-closed policy reads.
- CRITICAL. Unauthenticated relayer fund/gas drain (
/api/bridge,/api/gateway): no auth, no rate limit;MAX_BRIDGE_AMOUNTcaps only per call. Mitigated: the funded relayer key is removed from the live deployment, so these endpoints return "no relayer key configured" and cannot spend. To enable them, add authentication + per-address quotas first. - HIGH.
/api/gasless-claimwrong-code lock griefing: 5 attempts with a random code (only the publictransferIdneeded) permanently locks any transfer. Mitigated: gasless is disabled on the live deploy (no relayer/Circle key). Proper fix: authenticate the recipient and pre-check the code against the publicclaimHashbefore spending an attempt. - HIGH.
/api/cosignfirewall-scan DoS: varyingtargetmisses the verdict cache and forces a from-deploy-blockgetLogsscan each call. Mitigated: a per-IP sliding-window rate limit (40/min) now fronts every endpoint, on top of the verdict cache. Further: bound the scan range / use a dedicated indexer. - MEDIUM-HIGH: notification registry spoofing + storage DoS: anyone could register any push token for any address (surveil a victim's payments) or grow the store unbounded. Fixed: registration now requires a signature proving control of the address; per-address token cap (10); atomic store write.
- MEDIUM. Gasless error-detail leak. Documented: only reachable when gasless is enabled; map unknown errors to a generic message.
- Verified hardened: loopback bind behind nginx TLS (8787 not exposed), the co-signer trust boundary (target/nonce/remaining/expiry read from chain, client policy ignored, funds cannot be redirected: DoS only, no theft), fail-closed risk, 8 KB body cap, generic errors + no key logging, server-only keys.
Retired.
apps/mobilewas the Expo client. It has been removed from the repository: the shipping Android client is a separate native Kotlin/Compose app, which is not a port of it and does not inherit its code. The findings below are kept as the audit record for the Expo app and do not describe the native one; read them as history, not as current state. The/api/notifications/registerroute and the push watcher went with it, so the token registry that section B hardened no longer exists at all.
- HIGH. Claim QR is a bearer credential (code + salt in one QR): anyone who sees it can claim. Documented (ship-blocker): split the 6-digit code out-of-band from the salt QR; the contract's 5-attempt lockout already mitigates brute-forcing a salt-only QR.
- MEDIUM. Biometric gate failed open on error/no-enrollment. Fixed: fails closed on error and falls back to the device passcode when no biometric is enrolled.
- MEDIUM. Device key not backup-protected, no wipe. Fixed:
WHEN_UNLOCKED_THIS_DEVICE_ONLY(no backup extraction) + a "Remove wallet from this device" wipe action. Note: this is the interim dev-key model; the shipping wallet is Privy (embedded/passkey). - MEDIUM. Secret screens are screenshot/snapshot exposed. Documented: add
expo-screen-capture(FLAG_SECURE) on the secret-bearing screens before ship. - MEDIUM. No certificate pinning for a funds-moving app. Documented: pin the API + RPC + the co-signer address before public ship.
- LOW. Push-nav has no screen allowlist; http override example. Documented.
- Verified hardened: no key logging/clipboard/eval/exfiltration, hostile QR/JSON parsing is safe, https-only, CSPRNG salts, firewall enforced before send.
- Backend: keep the signed-request guard and per-address quotas on every route that spends the relayer's balance, and keep the loopback bind + TLS. The bridge routes this item was written for no longer exist; see the note at the end.
- Mobile: ship with Privy (the device key is interim), split the claim code out-of-band, and add screenshot protection + certificate pinning.
A follow-up pass closed the remaining findings with real tests:
- Backend relayer endpoints (was: CRITICAL, mitigated by disabling). Now
Fixed and safe to expose:
/api/bridge,/api/gateway,/api/gasless-claimrequire an EIP-191 signed request (bound to path + timestamp + body-hash, fresh within 120s) plus a per-address daily quota. Verified live and locally: an unsigned request is 401, a tampered body is 401, and the 11th over-quota call is 429. The relayer key stays out of the deployment until an operator enables it behind this guard. - Gasless lock-griefing (was: HIGH). Now Fixed: the code is checked against
the transfer's on-chain
claimHashoff-chain, so a wrong code is rejected before any sponsored/relayer transaction and cannot consume the 5-attempt lockout; errors are generic. Logic covered by theclaimCodetests. - Co-signer PULL policy (was: LOW). Now Fixed: an over-
perPullMaxor too-soon PULL is vetoed before signing; unit-tested. - Mobile claim QR bearer (was: HIGH) + snapshot exposure (was: MEDIUM). Now
Fixed: the QR carries only
transferId + salt; the 6-digit code is entered separately and never co-located with the QR; the secret screen blocks screenshots/snapshots (expo-screen-capture). - Co-signer owner binding (was: LOW/F3). Now Fixed: every
/api/cosignrequest carries an owner-signed message; the server recovers it and rejects a request whose signature does not match the claimedowner(or is stale). Verified: no-signature, forged-owner and stale requests are all vetoed. - Bridge amount normalization (was: LOW/#8). Now Fixed: only a canonical
USDC decimal is accepted and the validated value is what is forwarded. Verified:
5e-7,5.0000001and5are rejected. - Mobile co-signer pinning (part of M5). Now Fixed: the app pins the expected co-signer address and refuses to bake any other into an account, so a MITM cannot substitute an attacker-controlled co-signer.
- Co-signer scan performance (relates to the HIGH cosign DoS). A dedicated
VerifiedRecipientIndexbackfills theRecipientVerifiedevents once then polls incrementally, andcheck()takes the sender's verified recipients from it instead of scanning from the deploy block on every call. Measured live: the precheck dropped from ~220s (504 timeout) to ~3s for any target (two distinct targets, so not just the verdict cache). Together with the per-IP rate limit this closes the amplification surface too. - TLS certificate pinning (mobile): FIXED.
apps/mobile/plugins/withCertPinning.jsis an Expo config plugin that pins the SPKI hashes ofapi.ctrlarcz.xyzandrpc.testnet.arc.networkat the OS TLS layer (Androidnetwork_security_config<pin-set>+ iOSNSPinnedDomains), so it covers every backend fetch and every viem RPC call, not just app-level fetches. It pins the Let's Encrypt roots (ISRG Root X1/X2) plus the current issuing intermediates, surviving normal 90-day leaf rotation while still rejecting a rogue-CA / mis-issued certificate, the exact MITM pinning defends against. The Android pin-set carries anexpirationsafety valve so a stale pin can never brick the app in the field. Enforced in the native (EAS) build; Expo Go cannot pin. Validated viaexpo config --type introspect.
A second, deeper review ran five independent adversarial auditors over the whole system (contracts, SDK, backend + demo-kit, mobile, web). Each finding below was reproduced, fixed, and covered by a test or a live check. No CRITICAL/HIGH remained after this pass; the residuals were data-layer fail-open paths and abuse-prevention gaps rather than key/fund-theft vectors.
- HIGH. Provider swallowed its own errors, making the fail-closed branch dead
code.
BlockscoutDataProvider.getOutgoingCounterpartiescaught every fetch error and returned[], so under explorer degradation a poisoning lookalike downgraded towarning/safeand both the transfer and the co-signer proceeded. FIXED: the provider now REJECTS on a failed history fetch (IDataProviderdocuments this), socheck()marks the report incomplete and fails closed. Regression test added; the old test only passed because its fake provider threw where the real one never did. - HIGH. No pagination: only the most-recent ~50 counterparties were scanned, with
complete:true. A lookalike of an older counterparty slipped through silently. FIXED:getAllPagesfollowsnext_page_paramsup to a cap and returns acompleteflag; a truncated scan marks the report incomplete (never authoritative). Test added. - MEDIUM. Co-signer ignored
verdict.complete. FIXED:LocalCoSignernow vetoes any incomplete scan regardless of level (a successful bounded scan stays complete, so the cold-start path is unaffected). Test added. - MEDIUM. Owner-auth signature was a reusable bearer proof.
cosignAuthMessagebound only owner+ts. FIXED: it now binds the request scope (target/account/amount/ action); the server reconstructs and verifies it and rejects a replayed signature. Test added. - MEDIUM.
assertDeployedPolicychecked only 4 of 9 fields. FIXED: it now verifies token/cosigner/target/vaultHash/maxAmount/perPullMax/expiry/interval/mode (accounting for the contract's perPullMax==0→maxAmount normalization, a bug the live E2E caught). - LOW. Indexer backfill gap (events mined during the backfill were dropped while
isReady()reported ready). FIXED: the head is snapshotted before the scan and the poll resumes from it.
- HIGH. Rate limiter was bypassable via a spoofed
X-Forwarded-For. It keyed on the leftmost (client-controlled) hop. FIXED: it takes the rightmost hop nginx appended; a per-key ceiling and periodic sweep bound the map. - MEDIUM. Signed requests were replayable within the 120s window. FIXED: a single-use nonce store (keyed by signature hash) rejects repeats. Tested.
- MEDIUM. No global relayer spend cap (per-address only). FIXED: a process-wide daily unit ceiling. Tested.
- MEDIUM. 30-minute verdict cache TOCTOU. FIXED: TTL cut to 60s, verdicts computed before the indexer is ready are not cached, and the cache is swept.
- MEDIUM. Unbounded in-memory maps. FIXED: sweeps + ceilings on the rate-limit, quota, verdict, owner-sig, and notification stores.
- LOW. CORS reflected any Origin when unset. FIXED: fail closed (a browser Origin is allowed only if explicitly listed; same-origin web + no-Origin mobile are unaffected). LOW: notification registration replayable/unbounded: FIXED with a timestamped, freshness-checked message and a registry ceiling.
- HIGH. Biometric gate failed OPEN on a device with no biometric hardware. FIXED: it now falls back to the device passcode and fails closed when the device has no security at all.
- MEDIUM. The Send screen ignored
warning/incomplete verdicts. FIXED: a warning or an incomplete scan now routes to an explicit confirm step; onlyblockhard-stops. - MEDIUM. Cert "pinning" pinned the shared CA root. FIXED: pins the issuing intermediates (rejects a different CA) and drops the roots; residual LE-mis-issuance is covered by the on-chain co-signer pin.
- MEDIUM. One-tap irreversible wallet wipe. FIXED: a destructive-action confirm. LOW. Screenshot guard engaged earlier + claim code is tap-to-reveal; push nav is allow-listed to known routes; registration is timestamped.
- LOW. Claim code persisted to localStorage. FIXED: kept in memory for the session only, never written to disk. Under the single-secret design the salt is no longer persisted either, since it is derived from the secret and writing it down would be writing down the secret. LOW. The receiver no longer reads any part of the secret from a URL query param.
- LOW. Contract:
interval==0disabled the PULL rate limit. FIXED:initrequiresinterval > 0for PULL. NewSpendPolicyFactory(0x8AB90Dfe39D9c9bFE8bdDa84545FA734c02442B9) + implementation (0xa06419b913abA4BFdfEeb9D1A8800DbC2E3A2C11) redeployed and re-verified with the live create→pay→sweep E2E. LOW. The SDK now treats a benign deploy collision (someone force-deploys the identical account) as success rather than a hard error.
The claim proof used to be two parts: a spoken 6-digit code plus a 256-bit salt that
travelled in a claim link. Splitting it is what forced the link to exist, and any
attempt to deliver the second half some other way runs into the same wall: an
on-chain ciphertext, a backend, a push, all of them are keyed to the recipient
ADDRESS, and in a poisoning attack that address belongs to the attacker. Handing them
the salt leaves 20 bits between them and the money, grindable offline in a second
against the public claimHash.
It is now one secret, 16 Crockford base32 characters, 80 bits, shown grouped as
A4K7-9QMX-2PR6-TH8D. The salt is derived from it, so nothing travels but the string
itself and it has to reach a person. CodeClaimVerifier puts no constraint on the
code format, so this needed no contract change and no redeploy.
Consequences for entries written against the old design:
- "Split the 6-digit code out-of-band from the salt QR" (mobile, section C) no longer applies as written. There is no salt to put in a QR; the mobile QR carries only which transfer is meant, and the secret is typed. A QR carrying the secret would make a photographable bearer credential, so it does not.
- "The salt alone cannot claim" is no longer a reason to persist anything. The secret is the whole credential and is never written to disk.
- Attempt lockout. The 5-attempt limit still stands, now purely as an on-chain guessing bound rather than as compensation for a small preimage.
Verified on Arc Testnet: a transfer sent, a wrong secret rejected with the attempt counter, then settled with the right one including lowercase input and spaces in place of the dashes; and a gasless claim through the live API where the recipient received the full amount with their nonce unchanged.
POST /api/bridge, POST /api/gateway and GET /api/bridge/:jobId are gone. They
ran a cross-chain transfer from the relayer's own balance, capped at 5 USDC a call
and fenced behind a signed request and a daily quota, and every finding above about
them describes that route rather than anything a user signs today.
They are deleted because nothing called them. The web app signs the CCTP burn and
the Gateway spend in the browser (BridgeTab uses bridgeFromWallet,
depositToGateway and spendFromGateway), and the Android client signs both on the
device; a grep of its source for /api/bridge and /api/gateway returns nothing.
What that removes is not a bug but a budget. The quota bounded a caller to 50 USDC a
day and the process to 2000, out of the same balance that pays for box deploys,
stealth announcements and gasless claims, so an endpoint no client used could still
have stopped the ones they do. GET /api/health reports that balance and flags it
low for exactly this reason.
Four hardening changes from a re-audit, each verified live against the deployed contracts on Arc testnet. No contract bytecode changed; the fixes are in the SDK, the backend and the co-signer.
- The co-signer binds the owner to the box. A deployed spend box stores no
owner on chain, so the sign path accepted any caller who named a box and
authenticated as some address: a stranger could get a valid co-signature for
another payer's PULL box and drive its pulls. Funds still reach only the locked
target, but the owner never consented. The server now indexes the factory's
AccountCreated(ownerHash)and refuses to sign unlesskeccak256(owner)equals the box's ownerHash. Proven: a fresh wallet is rejected with "owner does not own this account"; the real owner still passes. - The firewall fails closed when its verified-recipient source is unavailable. A bounded fallback scan, used when the from-deploy-block index is down, was treated as authoritative, so a lookalike of a recipient outside the window passed as safe. The bounded scan is now marked incomplete, which makes the lookalike rule uncheckable and blocks the unknown target. Proven: the lookalike that scored only "caution" while the index returned 429 now blocks; a healthy index still warns rather than over-blocking an ordinary new address.
- Signed-request replay is closed to ECDSA malleability. The single-use nonce
was
keccak256(signature), which anston-stwin bypasses; it is now keyed by the recovered signer plus the signed message. Proven: the malleable twin, which used to return 200, now returns 401. - The zero-value bait scan is paged, so a bait an attacker pads past the first page of history is still counted.
Shipped 2026-08-24 (SpendPolicyAccount redeployed, factory
0x9B3b30573D7f1d65d1cFeeb52E3a6E69a5792161): sweepExpired now requires
msg.sender == vault, so a stranger who cracked the vault from its commitment can no
longer force the sweep and write the vault-to-box link the payer never chose to
reveal. The redeploy is safe for existing boxes: their pay/pull calldata is
unchanged, and the co-signer's owner-bind index now scans the prior factory too, so
an existing subscription is still recognised (verified end to end before deploy).
Deferred, because it cannot ship without breaking existing boxes: an expiry on the
co-signer's spend signature. It changes the pay/pull calldata, so a wallet that
sent it to a box created before the change would be rejected, stranding every open
subscription until a per-box version-detection layer is added. Its value is marginal
over the guards already there (the spend nonce makes each signature single-use, and
the box expiry bounds the window), so it waits for that layer rather than a rushed
migration.
Private Pay funds its box from the payer's own wallet in one instant transaction, so that funding is visible on chain by design (the success screen says so); its box commitment linkability adds nothing an observer cannot already read from that funding transaction.