This is the first stage that can move funds. It is off unless EXECUTION_ENABLED=true, and with it on, startup
fails unless the bundler, the secret key, the kernel wallet stack, Textile pricing and the passkey settings are all
configured. Read "What was and was not verified" before enabling it with any value.
Execution READY (Build 12)
-> recheck: authorization, firm quote window, intent revision, wallet, fresh balance, permission, allowance, gas
-> [account missing / permission missing] REQUIRES_USER_ACTION -> root action (passkey) -> chain read-back -> READY
-> acquire: lock the row, require READY, consume the PaymentAuthorization, READY -> SIGNING (one winner)
-> [APPROVAL_RESET (USDT-style, only if needed)] -> APPROVAL (exact amount) -> allowance visible on chain
-> SWAP + PAYOUT in ONE UserOperation: SUBMITTING -> SUBMITTED
-> receipt (userOpHash -> txHash) -> SETTLING: POST /v2/rfq/{id}/submit {txHash}, GET /v2/rfq/{id}
-> filled and inside the authorized bounds -> COMPLETED (claim token tombstoned)
COMPLETED is reached only through SETTLING, and SETTLING only after the swap UserOperation is included and
successful on chain. There is no READY -> COMPLETED. "Payment sent" is shown only for COMPLETED.
| Step | Signer | How |
|---|---|---|
| Deploy the smart account, install the restricted permission | the person's passkey (root) | The server prepares the UserOperation and its hash; the browser returns an assertion over exactly that hash; the adapter encodes it. No root key exists anywhere. The PIN is never involved. |
| Approval, swap, payout | a per-permission session key, restricted on chain | ValidatedExecutionSigner.signValidatedExecution(executionId) - the only entry point. |
The signer takes an execution id and nothing else (no bytes, calldata, address, amount, PIN or credential). It re-loads
the execution, re-validates the authorization against the operation (as of the moment the authorization was
consumed), re-derives the plan from stored facts plus fresh read-only chain state, checks the permission on chain,
builds the next unsent step itself, writes its Transaction row before sending, and sends once. A step that was
sent, or may have been, is never sent again.
Call policy: approve(spender, amount) only on the sell token, spender pinned to the quote's, amount <= the exact
allowance the quote needs; transfer(to, amount) only on the buy token, to pinned to the recipient, amount <= the
firm output; the swap target (any function, no native value); native value 0 everywhere. Timestamp policy: a 15-minute
window. The session key is generated per permission, stored only as AES-256-GCM ciphertext bound to its record, and
decrypted only inside sendDelegatedCalls.
Textile has no recipient field, so the swap pays the wallet and the recipient is paid by a transfer in the same
UserOperation. The batch is atomic: if the swap delivers less than the transfer, everything reverts and nothing is paid.
ExecutionPlanRepository.acquire runs in one database transaction: SELECT ... FOR UPDATE on the execution, require
READY, consume the PaymentAuthorization (ACTIVE and unexpired), set SIGNING and authorizationConsumedAt. Many
racing callers produce exactly one winner; a failure rolls both back. The consumption therefore happens immediately
before the first irreversible payment step - not on quote, plan, deployment or permission install. A failure after
that point keeps the authorization consumed (no silent replay) - with ONE exception, below. A database check
(Execution_consumed_before_signing) refuses any payment status from SIGNING on without authorizationConsumedAt.
If the firm quote has too little time left, one refresh is allowed under the same authorization (a new firm quote,
within its attempt cap). The second time it is a stop (REAUTHORIZATION_REQUIRED), not a loop.
When the bundler definitely refuses a payment step before anything was signed-and-submitted, nothing was sent and nothing moved, so
ExecutionPlanRepository.releaseBeforeSubmission restores the authorization (CONSUMED -> ACTIVE) in one database transaction with
the execution (FAILED) and the rejected step rows (their idempotency keys are retired so a new attempt starts clean). It applies only if
every payment step recorded is a BUNDLER_REJECTED row with no userOpHash and no transaction hash, the authorization is still unexpired,
and no other ACTIVE authorization exists for the intent. It never applies when a hash exists (the signed operation may have escaped), when a send was
ambiguous (UNKNOWN), when any earlier payment step ran (an approval already set an allowance on chain), or after a revert. Wallet-setup rows are
ignored. Nothing resumes by itself: a person must prepare the payment again; the firm quote is reused only while it is still valid.
- Every irreversible step has a
Transactionrow with a uniqueidempotencyKey(exec:<id>:<STEP>), created before the send. A row that exists but was never marked sent becomesUNKNOWN; it is reconciled, not resent. BUNDLER_REJECTED(definite: the bundler refused it) marks the step failed. Any other error after a send began (timeout, dropped connection) marks itUNKNOWN. An RPC outage while waiting for a receipt is not a failure.userOpHashandtxHashare stored separately. A reverted inclusion fails the payment.- After an approval is confirmed, the chain must show the allowance before the swap is sent.
COMPLETED needs (1) the swap UserOperation included and successful, and (2) the provider reporting the order
filled. The settled amounts are then checked against what the person authorized (EXACT_INPUT: input within the
authorized input and output at least the minimum; EXACT_OUTPUT: output at least the exact amount and input within the
maximum). Outside the bounds the execution is FAILED with SETTLEMENT_POLICY_VIOLATION and audited; the
authorization stays consumed. A failed or expired order that the provider says could still flip to filled is a state to
keep watching (PAYMENT_PENDING), never a verdict. The provider submit is a documented courtesy, idempotent for the
same hash; an unreachable provider is pending, not failed.
The claim token is decrypted only for the request that needs it and tombstoned (ExecutionSecret.ciphertext = NULL,
tombstonedAt) once the order is done or the execution failed. It is never logged and never part of an error.
ExecutionRunner.run(executionId) reads persisted state every time and continues from there, so it is safe to call
again at any point. reconcile() (a timer, the 30 s reconcile interval in settings.ts, plus a first pass shortly after
start; no queue) runs every unfinished execution. Running it twice, concurrently, or after a crash is safe because the
lock is a conditional database update.
ROOT_ACTION_REQUIRED ("Confirm wallet setup to continue payment") - PROCESSING_PAYMENT - PAYMENT_SENT -
PAYMENT_PENDING ("don't send it again") - PAYMENT_FAILED - REAUTHORIZATION_REQUIRED - GAS_FUNDING_REQUIRED -
INSUFFICIENT_BALANCE - EXECUTION_ROUTE_UNSUPPORTED. The API returns a state, a sentence and (for failures) a short
code. Never a claim token, UserOperation internals, calldata, a hash or an amount.
HTTP (/api/v1): POST execution/run, GET execution/status, POST execution/root-action-link (Bearer = the
authorization link token); GET root-action/view, GET root-action/options, POST root-action/complete (Bearer = the
opaque root-action link token; the body is { proof }, the root's proof and nothing else: a passkey assertion or a
device-key signature, whichever the wallet's root is; options and status carry the structured action; see
docs/root-agnostic-flow.md).
There is no paymaster: the smart account itself must hold native CELO. Below EXECUTION_MIN_NATIVE_WEI the person
sees GAS_FUNDING_REQUIRED before anything is consumed. A paymaster can be added later behind KernelExecutionPort.
Same-asset direct transfers (deferred to Build 13.1; a same-asset payment has no swap and is not planned), multi-hop
execution (still EXECUTION_ROUTE_UNSUPPORTED), Telegram/WhatsApp, recovery, scheduled/recurring payments, treasury
automation, a paymaster, arbitrary signing.
Verified offline (no network, chain or signer): the state machine, the lock under concurrency (in memory and on the real database), authorization consumption and rollback, idempotency, the signer boundary and its refusals, never resending after an ambiguous send, outage handling, settlement bounds, the claim-token lifecycle, secrets never in audit/logs/Transactions, the passkey assertion encoding (against the validator's ABI), the policy construction, and the source-scan guards.
NOT verified, because it needs a funded wallet, a real bundler and a real Textile order:
- the real ZeroDev/Kernel adapter (
packages/blockchain/src/execution/zerodev-kernel-adapter.ts) has been type-checked against the pinned SDK versions and its pure parts tested, but has never been run against a bundler or a chain; - that explicit
installValidations-style installation of the permission through the SDK helper succeeds, and that the serialized permission account (marked pre-installed) is accepted on first use; - that the call policy treats an omitted selector on the swap target as "any function" (the SDK pads it to
0x00000000; its on-chain meaning was not confirmed); - that a permission id (derived from policies and signer) read back from the account proves the policy parameters; it proves the permission exists with the intended signer and policy set, and the parameters are the ones in the enable data the passkey signed in the same operation;
- Textile's
submit/status behaviour beyond its documentation, and the exact order of the user-visible states at the provider; - ERC-4337 on Celo is not described in Textile's documentation; the bundler requirement is Kaada's, not Textile's.
Do not run this with meaningful funds. A first live test should use a throwaway wallet, a tiny amount and exactly one payment, and the observed sequence should be written back here.
No Celo mainnet execution testing during development (no budget for real CELO/USDT or a paid mainnet bundler). Mainnet stays read-only (and Textile preview). Execution is validated in three separate places, and nothing below is a Celo/Textile end-to-end validation; that happens once, at the end, on mainnet, if budget permits:
- Celo testnet (Celo Sepolia, chain 11142220) - the Kernel/ERC-4337 side only: derivation, passkey root, bundler, deployment, permission install/read-back, restricted signing, receipts, restart.
- Textile test environment (BNB testnet 97, Base Sepolia 84532) - the provider contract only: firm RFQ, exact input/output, claim token, approvals, submit, status, slots, failures. Not a change to Kaada's Celo architecture.
- Offline - fake bundler, scripted Textile, PostgreSQL integration, deterministic plans.
Findings: Celo Sepolia, read-only (chain id confirmed 11142220 via eth_chainId on the official Forno RPC)
Contract code at the SDK's pinned addresses (eth_getCode; the same addresses as mainnet):
| Contract | Celo Sepolia |
|---|---|
EntryPoint v0.7 0x0000000071727De22E5E9d8BAf0edAc6f37da032 |
present |
| Kernel v3.3 implementation, factory, meta factory | present |
| CallPolicy v0.0.4, TimestampPolicy, ECDSA signer | present |
Passkey (WebAuthn) validator v0.0.3 0x7ab16Ff354AcB328452F1D445b3Ddee9a91e9e69 |
ABSENT (present on mainnet) |
So the passkey root validator does not exist on Celo Sepolia. A testnet run of the passkey path needs either a copy of
that validator deployed there by us (its address is a parameter of the SDK) or a different root for testnet only. This
is a testnet-fixture decision, not a change to the production design. Celo Sepolia chain facts (from the Celo docs): RPC
https://forno.celo-sepolia.celo-testnet.org, Blockscout celo-sepolia.blockscout.com, faucets at
faucet.celo.org/celo-sepolia and the Google Cloud Web3 faucet. Alfajores (44787) is being retired.
The configured ZeroDev project answers HTTP 402 ("No Plan found for projectId") on every call, so that URL is unusable for any network until a plan exists. No bundler for Celo Sepolia has been confirmed yet.
The RIP-7212 P-256 precompile (0x100) accepts a valid signature on both Celo Sepolia and mainnet (checked with a
freshly generated key; it returns 1). The SDK's own list of precompile networks does not include Celo Sepolia, so the
adapter now takes an explicit KernelNetwork (chain, optional passkey-validator address, p256Precompile) that
defaults to Celo mainnet. The Daimo fallback P-256 verifier is absent on Sepolia, so the testnet must use the
precompile path.
Decided: bundler = an Alchemy key for Celo Sepolia (separate BUNDLER_URL for the testnet run), and the passkey
validator is deployed to Celo Sepolia from a faucet-funded throwaway key as a testnet-only fixture.
Run with packages/blockchain/src/testnet/celo-sepolia-harness.ts (standalone; state and throwaway keys live outside
the repository; nothing imports it; mainnet configuration untouched). Chain 11142220, faucet CELO only, a
software P-256 passkey (not a real browser authenticator), the passkey-validator fixture deployed to Sepolia (runtime
byte-identical to mainnet), the RIP-7212 precompile path. Nothing here validates Textile, USDT, wBRL or Celo mainnet.
| Property | Result |
|---|---|
| Counterfactual Kernel v3.3 address from a passkey root (with the fixture validator) | derived; no code until first use |
| Deployment + permission install | one UserOperation does both (account initCode, then installValidations + grantAccess); UserOperationEvent.success = true; account is a 61-byte proxy; root validator = the fixture |
| Passkey root signing | an assertion over the server-fixed challenge (= the UserOperation hash) is encoded by the adapter and accepted on chain through the P-256 precompile |
| Permission installation | succeeded with the corrected install calls (see below) |
| Permission read-back | exact: permissionConfig(id) returns the ECDSA signer module and the ordered policy contracts; the id matches only for the exact scope. A changed spender reads back false |
| Policy parameters verifiable on chain? | Yes, by id. Kernel's permission id hashes the policy contracts and their data (call rules, timestamp window) plus the signer, so an id match proves the exact parameters. The window is part of that hash |
| Omitted selector | NOT a wildcard. It is the literal selector 0x00000000: an arbitrary call to the swap target was refused. The scope now pins the swap's real 4-byte selector (swapSelector) |
| Restricted session-key signing | approve(spender, 1000) on the token succeeded; allowance read 1000. A swap-shaped call (pinned selector) plus transfer(recipient, 100) in one batch succeeded; the recipient received exactly the amount |
| Refusals (estimation only, nothing sent) | approve to another spender; approve above the limit; transfer to another recipient; transfer above the payout limit; a different selector on the swap target; native value on the swap target; an unlisted address; an unlisted selector (transferFrom) - all refused in validateUserOp |
| Receipts and restart | a brand-new adapter recovers each operation from its stored userOpHash (bundler, with a chain fallback on the EntryPoint UserOperationEvent) and confirms the account, allowance and permission from persisted state |
- Wrong install calls. The SDK helper
getValidatorPluginInstallModuleDatais for ordinary validators; for a permission it reverts with no reason. The install isinstallValidations(0x02 ++ permissionId, {nonce, hook}, enableData)plusgrantAccess(id, execute, true), as the SDK's owntoInitConfigdoes. - Omitted selector was assumed to mean "any function" (it does not); the real swap selector is now pinned (and a calldata without one is a plan blocker).
- Read-back used a fresh window. The permission id hashes the validity window, so read-back with a window recomputed from "now" could
never match; read-back now uses the stored permission's window (
installedScope). - Stub signature selected the on-chain fallback P-256 verifier, which does not exist on Sepolia (validation reverts,
AA23); the stub now follows the network's precompile flag. - Fee estimation used
zd_getUserOperationGasPrice, a ZeroDev-only method; Alchemy answers "Unsupported method". Fees now come from the chain. - RPC node lag. Immediately after inclusion an RPC node can be a block behind: the permission read-back returned
falseand the next operation was rejected withAA25 invalid account nonce. The runner now waits until its RPC node has seen a transaction before reading or building the next step. - Receipt lookup depended on one bundler's index; it now falls back to the chain.
Alchemy (Celo Sepolia). Capability check passed (chain id, EntryPoint v0.7 listed, receipt method present), but three
operations sent through its eth_sendUserOperation were accepted and never bundled (10+ minutes), although
EntryPoint.handleOps simulated cleanly and the same operations mined when submitted directly. Cause unknown; not pursued.
Pimlico Alto, self-hosted (test infrastructure only; nothing Alto-specific is in domain or application code).
| Package / CLI | @pimlico/alto 0.0.21 (the CLI prints 1.0.0), Node 22 on Windows |
| RPC | https://forno.celo-sepolia.celo-testnet.org, chain 0xaa044c = 11142220 |
| EntryPoint | v0.7 0x0000000071727De22E5E9d8BAf0edAc6f37da032 (eth_supportedEntryPoints lists exactly that one) |
| Flags | --chain-type op-stack --safe-mode false --block-time 1000, one dedicated executor EOA and one utility EOA (throwaway, outside the repo) |
| Funding | executor 0.3 CELO; utility 0.3 + 0.5 CELO (see quirks), faucet funds only |
| Root UserOperation (passkey, installs a second permission) | accepted and mined by Alto in ~5 s; chain tx success; UserOperationEvent.success = true; the returned userOpHash equals the signed challenge |
| Permission read-back | exact scope with the installed window: true |
| Delegated UserOperation (session key, a 7-wei payout) | accepted and mined by Alto in ~6 s; the recipient received exactly 7 wei |
userOpHash -> txHash |
different values; Alto's eth_getUserOperationReceipt returns success, receipt.transactionHash, receipt.blockNumber; null until mined |
| Recovery (new process, stored hashes only) | both operations resolve to the recorded txHash; read-back from the persisted window true |
handleOps fallback |
not used for these steps |
Quirks of Alto on this chain: (1) startup deploys "simulations" contracts through the deterministic deployer; the utility key needs enough
CELO (Celo Sepolia gas is ~50 gwei; with 0.3 CELO the EntryPoint v0.7 simulations deploy failed with an unhelpful "execution reverted" from
gas estimation); (2) the v0.9 simulations contract fails to deploy (irrelevant: only EntryPoint v0.7 is used); (3) Forno has no tracing, so
--safe-mode false (ERC-7562 rules are not enforced locally, which is fine for a test bundler, not for production).
A real browser passkey (only a software authenticator was used); a production-grade bundler on Celo (Alto here is a local test bundler with safe mode off); USDT and the USDT reset-to-zero path (the test token was CELO's ERC-20 face, which
does not need one); the ExecutionRunner end to end on a chain (it was exercised against fakes and the real database, and its pieces against
Sepolia, not as one run); Textile (not touched); anything on Celo mainnet.
Script: apps/api/test/testnet/celo-sepolia-runner.ts (not part of pnpm test; testnet only). It runs the production ExecutionRunner,
ValidatedExecutionSigner, RootActionService, preparation and firm-quote services over the real Neon database and the real Kernel
adapter on Sepolia, with the bundler = local Alto and no handleOps fallback. Substituted, and stated plainly: the provider (a fake firm
quote and a fake order status; no Textile), two test ERC-20s and a mock swap contract (src/testnet/contracts/Fixtures.sol: it pulls the
approved sell token and pays the buy token, so the approval is consumed for real), and the chain-read ports: the production readers
refuse any chain but Celo mainnet, so the runner is given Sepolia-backed readers behind the same Kaada ports. The database keeps the logical
chain id 42220; only the transport is Sepolia. This is test wiring, not a change to the production chain configuration.
| Scenario | Result |
|---|---|
| Normal run | run #1 -> ROOT_ACTION_REQUIRED (authorization still ACTIVE); passkey root action through Alto installs the permission; run #2: transitions REQUIRES_USER_ACTION -> READY -> acquire:ACQUIRED -> SUBMITTING -> SUBMITTED -> SETTLING -> COMPLETED. Rows: ROOT_ACTION (permission install), APPROVAL, SWAP, each CONFIRMED with a userOpHash and a different txHash. The recipient received exactly the 100 tBRL; the wallet paid exactly 20 tUSDT; the bounded allowance was fully consumed (0 after). The authorization was consumed after the root action and before the first payment row. Settled amounts recorded; claim token tombstoned; one provider submit |
| Racing runners (3 calls on 2 independent "processes") | exactly one acquire:ACQUIRED, the others NOT_READY; one SWAP row, one payout; the losers reported PROCESSING_PAYMENT; a final call returned PAYMENT_SENT |
| Allowance already sufficient | no approval step is recorded; the permission scope is unchanged (see defect 1) |
- The permission scope shrank mid-run. The scope was built from "is an approval needed now". After the approval confirmed, the plan was re-derived without it, the scope no longer matched the installed permission, and the signer correctly refused
PERMISSION_NOT_INSTALLED. The scope now depends only on the quote (the approval leg is present whenever the quote has a spender, pinned to that spender and the exact amount). - A completed payment was failed. With an allowance that was already sufficient, the swap consumed it; the re-derived plan "needed" an approval again and the runner tried to send it after the swap and failed the execution. Once the swap is confirmed nothing before it is due again.
- A malformed audit id aborted the run right after a UserOperation was sent (
entityIdis a UUID; the runner passed an idempotency key). Fixed, and audit writes can no longer abort a run (they are best-effort and logged). - Gas floor. One Celo Sepolia UserOperation prefunds ~0.05-0.1 CELO at ~60 gwei; the default floor was 0.001 CELO, so the check passed and the root-operation estimate then failed with
AA21as an exception. The default floor is now 0.05 CELO, and a prefund refusal while preparing the root operation is reported asGAS_FUNDING_REQUIRED. - Crash after the bundler accepted an operation (design review, found while writing the restart scenario). The UserOperation hash was recorded only after the send returned; a crash in between left a payment on chain with no hash to reconcile it by. The hash is computable after signing, so it is now persisted before submitting (
beforeSubmit->recordUserOpHash); the root operation's hash is its challenge and is recorded before it is sent. Covered offline (the approval and the swap each reach the bundler, the process "dies", a restarted reconciler finishes the payment with no resend).
| Scenario | Result |
|---|---|
| Fresh payment, current code | ROOT_ACTION_REQUIRED -> passkey root action through Alto -> PAYMENT_SENT; transitions REQUIRES_USER_ACTION -> READY -> acquire:ACQUIRED -> SUBMITTING -> SUBMITTED -> SETTLING -> COMPLETED. Rows CONFIRMED with a userOpHash and a different txHash. Recipient +exactly 100 tBRL, wallet -exactly 20 tUSDT, allowance 0 afterwards (bounded and fully consumed). Authorization consumed after the root action and before the first payment row. Settled amounts recorded, claim tombstoned, one provider submit |
| Crash after the bundler accepted the swap | The process-1 run returned PAYMENT_PENDING; the execution sat in SUBMITTING; the SWAP row was UNKNOWN with its userOpHash already stored and no txHash. A brand-new runner over the same database (reconcile) found the operation by that stored hash and completed it: PAYMENT_SENT, the same userOpHash, txHash filled in. One SWAP row, one payout on chain, no resubmission, authorization stayed CONSUMED |
| Known bundler rejection | A tampered approve spender (outside the permission) is refused by the bundler during preparation: the row is FAILED / BUNDLER_REJECTED with no userOpHash (nothing was submitted, unlike the ambiguous case above); the run returned PAYMENT_FAILED; running again created no new row and sent nothing; no funds moved; the authorization stayed CONSUMED at the time of that live run; the release described above was added afterwards |
The racing-runner scenario was not repeated: the later fixes touched the scope, the send path and the audit write, not the lock
(acquire) or the single-winner transition.
The earlier "crash" attempt in this session was invalid (the helper that performed the root action also ran the payment, so the simulated crash happened inside it and the same process recovered); the script was corrected so the crashing run's outcome is observed and recovery is done by a fresh process.
A production-grade bundler on Celo (Alto here is local, safe mode off); a real browser passkey (software authenticator only); Textile; USDT and the USDT reset-to-zero path; mainnet; the application's own HTTP/UI path (the runner was driven directly, with the root-action service called as the page would). A definitely-rejected operation that provably sent nothing now releases the authorization (see above); this was verified offline and on the real database, not repeated on Sepolia.
Scripts: apps/api/test/testnet/sepolia-app.ts (the real AppModule, composed for Sepolia: only the mainnet/Textile-bound pieces are replaced behind the
same ports: Kernel deriver with the validator fixture, chain-read ports, the Kernel adapter pointed at Alto, and fake quote/order providers),
sepolia-payment.ts (test data and funding) and sepolia-negative.ts (server-side rejection checks). The browser was a real Chrome with Windows
(Microsoft Password Manager) passkeys and a real fingerprint; no software passkey produced any PASS below. The database keeps the logical chain id 42220;
the transport is Sepolia, the bundler is a local Alto, tokens are testnet fixtures, nothing touches Textile or mainnet.
| Check | Result |
|---|---|
Passkey creation on /setup/[token] |
PASS: attestation verified with UV=1 (BE=1, BS=1, platform); only the public coordinates are stored (credentialId, publicKeyX/Y, rpId); RP ID localhost, origin http://localhost:3000 |
| Counterfactual address | PASS: the backend's wallet equals an independent derivation from the stored public key with the fixture validator (and differs from the mainnet-validator address, as it must) |
| PIN (new, via the secure page) | PASS: saved only as an Argon2 hash; no 4-digit plaintext in any column; never in a URL, request log, local/session storage or cookies (checked in the browser) |
/authorize/[token]: PIN -> PaymentAuthorization |
PASS over HTTP; the link is opaque and single-purpose; an expired or consumed link answers 401 |
| Root action (real passkey signs the server's challenge) | PASS: the Kernel account was deployed and the permission installed by the browser-signed UserOperation through Alto; the on-chain read-back matched; the userOpHash equals the signed challenge |
| Payment through the application path | PASS (three times): approval, then swap + payout, then settlement; payout exactly 100 tBRL each; wallet -20 tUSDT each; the bounded allowance back to 0; stored userOpHash equals the EntryPoint event and differs from the txHash; the authorization was consumed after the root action and before the first payment row |
| Reload mid-flow | PASS: the PIN page after a reload resumed at the PIN step with no second passkey |
| Cancelling the passkey prompt | PASS: nothing changed, a retry works (the message is now "Passkey confirmation was cancelled") |
| Reusing a completed root-action link | PASS: refused (401) |
| Server-side rejections over HTTP (15 checks, software passkey used ONLY to prove refusals) | PASS: wrong challenge, wrong origin, wrong RP ID, an assertion without user verification, a replayed registration, a replayed PIN assertion, no/unknown token, unknown root-action token, a spent setup link; each against a control that succeeds |
- The authenticator returned UV=0 when the credential was named. A real Windows provider signed with
UV=0(no verification) wheneverallowCredentialswas present, even withuserVerification: "required"and a fingerprint; with noallowCredentialsit returnedUV=1. Kaada correctly refused the UV=0 assertion. The sign-in options (PIN enrollment and the root-action page) no longer name the credential; the server still requires UV and still binds the assertion to the user's own stored credential. A local probe page showed the A/B/C (named) = UV 0, D (not named) = UV 1. - The payment page replaced the root-action link on every poll, invalidating the link the person clicked (
GET /root-action/view 401). It now asks for the link once and keeps it. - A real payment was marked FAILED although it succeeded on chain. The UI's
runand the 30-second reconciler overlapped at the swap step; the signer let any caller that found the step row inCREATEDsend it; the second send was refused (same nonce) and that refusal failed the execution. Fixed in three layers: only the caller that created a step row may send it; a refusal cannot fail an execution while another attempt's payment is moving; one drive per execution per process. - The wallet row stayed
COUNTERFACTUALafter the browser-signed deployment: now recordedDEPLOYEDonce the chain confirms it. - The cancel message on the wallet-setup page was generic: now specific.
- (Test composition) a balance read on a token that does not exist on Sepolia threw; it is a zero balance.
The final "Payment sent." text on the payment page was not directly witnessed by the person (they were on the wallet-setup page when it completed); the server shows the execution COMPLETED.
A production bundler, Textile with a real wallet, Celo mainnet, a second device/account, and recovery are untested. The payment authorization lifetime was 3 minutes (now 10 by default) and was too short for a
first-time wallet setup on a real browser; this run used 10 minutes through configuration.