XeniaEscrow — contract, tests, tooling, and both deployments - #1
Merged
Conversation
XeniaEscrow is the anonymizer the pool calls during a claim. It extends the reference escrow from STRK20 by Example with the three things Xenia needs: - Expiry and refund, so a lost link does not strand tokens forever. - Link keys instead of bare secrets. The reference authorises a claim with a raw preimage, which sits in public calldata and is replayable by anyone who sees the transaction before inclusion. Xenia binds the authorisation to the claimant's address with a STARK-curve signature, so a copied claim cannot be redirected. - An event on every state-changing path. The reference emits none, and the sprint validator checks transactions for events. Deposit stores the commitment and returns an empty span, since the pool has already withdrawn the tokens to this contract. Claim and refund share one settle path: look the entry up, check the window, verify the signature, flip `claimed` exactly once, and approve the pool to pull. Refund is authorised by a signature under its own domain tag rather than by matching a caller. `privacy_invoke` is always called by the pool, so `get_caller_address()` can never be the sender, and private transactions are submitted by rotating relayers besides. The deviation is documented in INTERFACE.md. Domain tags match the client's strings so both sides hash identically. Tests cover all nine cases the PRD requires plus deposit validation and a signature-replay case. They run in CI: starknet-foundry publishes no Windows binary and building it from source needs more memory than the contract machine has, so Linux runners are where the suite actually executes.
The contract machine cannot run snforge locally: starknet-foundry ships no Windows binary, and building it from source exhausts memory. CI is therefore the only place the suite executes, so a push has to produce results on its own rather than waiting for a pull request to be opened.
sncast has no Windows binary and does not build from source on the contract machine, so the deploy path needed something that works with the Node already installed. This reads the Sierra and CASM that `scarb build` produces and does the declare and deploy through starknet.js. Two guards, because the constructor argument is immutable and `privacy_invoke` asserts the caller matches it on every path. Deploying against the wrong pool gives a contract where every call reverts CALLER_NOT_PRIVACY, fixable only by redeploying and redoing every transaction: - On mainnet, POOL_ADDRESS must equal the verified mainnet pool. The address published in the STRK20 docs is Sepolia, which is the easy mistake. - Mainnet also requires CONFIRM_MAINNET=yes. `--dry-run` computes the class hash without a network or an account, which is enough to check the artifacts are readable before spending anything. Kept in its own package so contract tooling does not depend on the Next.js build.
…ckage
CI failed in `scarb build` with:
failed to remove directory .../checkouts/starkware-starknet-utils-.../3e2fd53
Directory not empty (os error 39)
`scarb fmt --check` resolves dependencies and populates the cache, then
`scarb build` re-checks-out the same git repository and cannot replace it. The
log shows starkware-starknet-utils being fetched twice inside one invocation,
once for each package in the privacy workspace that depends on it.
Xenia used exactly one item from that dependency: `OpenNoteDeposit`, a struct
of three fields. Importing it pulled in the entire privacy workspace —
starkware_utils, ekubo, and the vesu and shadow anonymizer packages — none of
which this contract touches.
Mirroring the struct removes every git dependency. Serde is structural, so the
encoding is unchanged: the pool deserialises three felts per entry in the same
order and never sees a type name. The ABI confirms the shape is identical.
`open_note.cairo` records the provenance and the upstream link to check before
a mainnet deploy.
The build no longer fetches any git repository, which also makes it fast and
deterministic — worth more than the import, given the tests only ever run in
CI.
The last CI run went green having verified nothing:
Collected 0 test(s) from xenia package
Tests: 0 passed, 0 failed, 0 ignored, 0 filtered out
Scarb.toml declared `[[test]] name = "xenia_unittest"`, copied from the privacy
package, where it configures a unit-test target rooted at src/. Declaring it
overrides Scarb's auto-detection of tests/, so the suite in
tests/test_xenia_escrow.cairo was never built into a target. snforge collected
nothing and exited 0, because finding no tests is not an error to it.
Removing the override restores auto-detection. tests/lib.cairo makes the
integration crate root explicit rather than inferred. The compiled target now
carries all fifteen tests, each expanded by the snforge plugin.
CI now fails when snforge collects no tests. A suite that silently stops
running is worse than one that fails, and this is the second time a green check
has meant less than it appeared to.
Four changes in src/lib/xenia/, with the reasoning and the exact code. Two of them are the PRD's fault rather than the client's: §5.1/§5.2 show 6-element calldata against a 10-parameter entrypoint, and §4.4.6 specifies a refund check that cannot be implemented. Both are noted as such. Also records what has been confirmed since the PRD was written: the withdraw-then-invoke shape with an empty span is valid, the mainnet pool fee is 6 STRK per transaction, and every listed mainnet transaction must be tied to XeniaEscrow, which makes refund a demo path.
The deploy script needed a private key in the environment, which invited pasting one somewhere it should not go. Credentials now live in contracts/scripts/.env, loaded by Node's --env-file, and .env is already covered by the repo's ignore rules. .env.example carries the verified Sepolia and mainnet values with mainnet commented out, so the default path is a testnet rehearsal. Sepolia RPC verified reachable and the Sepolia pool confirmed deployed at 0x0254a6...0d91.
Three things waste a deploy attempt, and all three are cheaper to catch before submitting: the account not being deployed on-chain, the private key not controlling the address, and the account being unable to pay for the declare. The first is the common one. Starknet accounts are contracts, so a wallet shows a usable address and a faucet will happily fund it while the chain still has nothing there. The balance is reported either way, because "funded but not deployed" and "not funded" need different fixes and look identical otherwise. Run before both the Sepolia and the mainnet deploy.
…unt call Two things the first real Sepolia run surfaced, neither of which the dry run could have caught: starknet.js v10 replaced the positional Account constructor with an options object. The old form binds `provider` to `options`, leaving `address` undefined, and fails inside the library on `address.toLowerCase()` — which reads like a bug in the caller. Argent/Ready accounts from v0.4 validate against Array<SignerSignature>, not a bare [r, s], so the default signer is rejected with `argent/invalid-signature-length`. ArgentV4Signer emits the five-felt encoding [1, 0, pubkey, r, s]. Every signing path funnels through signRaw, so the one override covers declare, deploy and invoke. Selected with ACCOUNT_TYPE=argent; `standard` keeps the plain form for OpenZeppelin-style accounts. Note this is not sufficient for an account with a guardian set: Argent Shield requires an owner and a guardian signature, and the guardian key is held by Argent, so such an account cannot be driven from a script at all.
Live at 0x4564195cae51bab74923df3029c43a4f27149b361488235c7e3ff1ea1374b81, pointing at the Sepolia pool. Verified after the fact rather than assumed: privacy_contract() returns the expected pool, and get_claim on an unknown commitment returns the all-zero not-found sentinel. new-deployer.mjs creates a purpose-built OpenZeppelin deployer account, because a wallet account with Argent Shield carries a guardian and needs a second signature held by Argent, so it cannot be driven from a script. The generated key is written to .env and never printed. DEPLOYMENTS.md records the addresses, the four failure modes this rehearsal caught before mainnet, and the mainnet checklist.
Rather than wait on support, pull every ViewingKeySet event the pool has emitted and look at what else happened inside those transactions. Registration does bundle into a larger transaction, and is doing so in production today — but every bundled case rides alongside a Deposit. None is a pure receive, which is Xenia's shape. That narrows the unknown considerably and supports bundling a small self-deposit into the claim as a workaround. The fee is fronted publicly by a relayer and then reimbursed out of the pool, which is why a claimant needs no public STRK but something must still fund the outbound 6 STRK. In the transaction traced, the user's own deposit covered it. Standalone registrations are plain invokes calling apply_actions directly on the pool, so a dapp can register a user itself. The two-step fallback is two clicks on our page rather than a trip into the wallet's settings, which was the difference that mattered.
The remaining unknown split into a wallet half and a protocol half. The protocol half needs no browser and no support reply. Pool transactions track a per-token running balance: deposit and use_note add, withdraw and create_note subtract, and the total must be exactly zero, with checked_sub panicking on the way. invoke_external has no balance effect at all — the escrow's OpenNoteDeposit is applied server-side, outside this accounting. So a first-time claimant's transaction has a 6 STRK fee withdrawal and no inflow to balance it, and the pool rejects it regardless of which wallet built it. The mainnet transaction traced earlier fits the invariant exactly: +8 deposit, -2 note, -6 fee. That leaves exactly two viable claim shapes: a sponsored flow with no fee withdrawal at all, or bundling a deposit that covers the fee. The second is buildable without anyone's permission and is already a proven shape on mainnet.
Classified 18 real pool transactions carrying Deposit or OpenNoteCreated by whether they emit a fee-reimbursement Withdrawal and who paid the fee. All 18 were relayer-submitted and reimbursed from the pool. None sponsored, none self-paid. With the three standalone registrations examined earlier, the split is clean: registration alone is self-submitted and the fee paid publicly with no withdrawal, while every note-bearing transaction goes through a relayer that the pool reimburses. A claim is note-bearing, so the withdrawal is not optional in practice, and it is what needs an inflow. That leaves bundling a deposit as the only demonstrated path, which is what all 18 of those transactions are doing.
Answers the three things we cannot determine from a script, without spending anything: does this wallet implement the STRK20 methods, has this account ever registered, and what does the pool currently charge. wallet_strk20Balances is read-only and safe against any wallet; a wallet that answers "not implemented" has told us the Wallet API route is closed to it. The registration check reads the pool's stored viewing key directly over RPC, so a zero means the account is still usable for the register-and-claim test. Plain HTML with no dependencies — open it in the browser that has the wallet extension. Nothing is submitted.
The probe reported no wallet with Ready installed. Two reasons: newer builds announce themselves through the Wallet Standard event protocol rather than writing window.starknet_<name>, and extensions generally do not inject into file:// URLs at all. Discovery now covers both mechanisms and rescans twice after load, since registration can land a beat late. A diagnostics panel reports the page origin, any legacy objects, any wallets that registered, and their feature names, so a failed detection still says something useful. The file:// case gets its own message rather than looking like a missing extension.
The probe found Brave Wallet, which registers with only solana:* features, picked it as the only candidate, and reported "wallet.request is not a function" — which reads like a bug in the page rather than the actual finding, that no Starknet wallet is present. Wallets are now classified before use: legacy injections must expose request or enable, and Wallet Standard entries must carry a starknet: feature. When wallets are found but none are Starknet-capable, the page says exactly that and names what it did find.
The probe reported no Starknet wallet on a browser where Ready X was installed and unlocked. The fault was the detection, not the browser: Object.keys returns only enumerable properties, and wallet extensions routinely define themselves with enumerable: false, so the scan could not see a wallet that was present. Detection now uses getOwnPropertyNames and additionally probes the known wallet keys by name, guarding each read in case a getter throws. The diagnostics report both the enumerable and the full property scan, so the two can be compared when a wallet still does not appear.
The probe called wallet_strk20Balances with no parameters, got INVALID_REQUEST_PAYLOAD, and concluded the wallet could not drive a claim. That is backwards: the wallet recognised the method and rejected the arguments. A missing method fails differently. It now checks the Wallet API version first — STRK20 rides on 0.10.3 and later, and Ready reports exactly that — then tries the plausible payload shapes for wallet_strk20Balances and reports which one is accepted. A payload complaint is recorded as proof the method exists rather than proof it does not.
A claimant holding nothing cannot claim: the pool charges a fee in STRK and its balance invariant requires that outflow to be matched by an inflow in the same transaction. Sending them the fee ahead of time supplies it. The funds come from the escrow rather than the sender's own address, which matters more than it looks. Had the sender funded the claimant directly, the public trail sender -> claimant would expose exactly the edge Xenia exists to hide. Paying out of a shared contract leaks no such thing. Two parameters already passed as zero on Deposit — claimant and note_id — carry the recipient and the amount instead of appending new ones, because the pool deserialises calldata positionally and appending would change its length and break every existing caller. Zero keeps the old behaviour exactly, so this is opt-in and the client's calldata shape does not move. The fee token is a constructor argument rather than a constant, since a claim denominated in USDC still owes its fee in STRK. ClaimPrefunded is a separate event so ClaimCreated keeps the shape the client already reads. 17 tests, including that pre-funding transfers and emits, and that a deposit without it is byte-for-byte unchanged.
The client derives the link key, hashes the commitment and signs the claim in JavaScript; the contract recomputes all of it in Cairo. A disagreement over a domain tag, a hash-padding convention or an argument order reverts every claim — and would do so first on mainnet, with real money, having passed every isolated test on both sides. So the reference values are produced by JavaScript, using the same calls src/lib/xenia/crypto.ts makes, and asserted in Cairo. CI becomes the cross-language check. js-reference.mjs regenerates them if the derivation changes. Five cases: the commitment, the claim message and the refund message all match; a signature made by ec.starkCurve.sign satisfies check_ecdsa_signature; and that same signature fails against the refund message, so domain separation is doing its job.
Supersedes the 25 August deployment, whose constructor predates the fee token argument. Verified live rather than assumed: the pool address is right, an unknown commitment returns the not-found sentinel, and the deployed ABI carries ClaimPrefunded alongside the original three events.
A commit touching only DEPLOYMENTS.md failed the build step while the commit before it, with identical contract code, passed. That is the registry deciding whether the suite runs, so the step now retries three times before giving up. setup-scarb also reported "failed to find Scarb.lock" on every run, because the lock lives in contracts/ rather than the repo root. Pointing it there restores dependency caching, which makes the fetch it was failing on less likely in the first place.
The existing suite proves the escrow's logic but cheats the caller address and calls through a typed dispatcher, which leaves the parts of the integration a client actually trips over untested: flat calldata deserialised positionally into ten parameters, a returned span the pool has to deserialise, and an approval the pool then pulls against. MockPrivacyPool does the real handshake — transfers the input tokens, calls privacy_invoke by INVOKE_SELECTOR with a raw Span<felt252>, deserialises Span<OpenNoteDeposit>, and transfer_froms what it was told to credit. The calldata arrays in the tests are byte-for-byte what CLIENT-FIXES.md tells the client to send, so a shape mismatch fails here rather than on mainnet. Four cases: a deposit parks the funds and returns an empty span; a claim credits exactly one note and the pool can pull it, which only works because the escrow approved first; a refund after expiry returns the funds the same way; and a claim redirected to another address reverts. 26 tests. The mock stays behind the test_contracts feature — a clean build still produces XeniaEscrow alone.
Live at 0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095, class hash identical to the Sepolia deployment, so the code that passed the suite is exactly what is on chain. Verified after the fact rather than assumed: the chain is SN_MAIN, privacy_contract() returns the mainnet pool — the immutable setting that would have bricked every call had it been wrong — get_claim returns the not-found sentinel, and all four events are in the ABI. Cost about 10.3 STRK. Worth recording that the declare reserves a ceiling near 21 STRK before it will run even though it charges roughly half: fund above the ceiling, not above the expected cost. strk20.json now lists the contract. Listing it raises the bar on the transactions: each one must also be tied to this address, so a plain shield will not count towards the three.
Two of the four client defects came from this document: §5.1 and §5.2 showed six calldata elements against the ten-parameter entrypoint §4.1 freezes. Jadon built to §5 in good faith. Both now show all ten with unused positions zeroed, and note that Claim and Refund pass the link public key rather than the commitment. §4.4.6 specified a refund check that cannot exist — privacy_invoke is always called by the pool, so get_caller_address() can never be the sender. Replaced with what the contract does and why, and §4.7's refund case reworded to match. §0's three blockers are all settled, two of them by measuring mainnet rather than waiting for a reply, so it now records the answers and what they imply instead of the questions. §3 carries the confirmed validator rule: with contracts non-empty a plain shield does not count, so the three transactions must be create-claim, claim and refund. §5.3 records the route as settled and names what is still unproven. §5.4 carries the measured fee. PROGRESS said it was the 23rd. It now opens with where each half actually stands and logs what this week established.
Four things had gone stale as the work moved on. CLIENT-FIXES said the contract was deployed nowhere, which softened the urgency; it is live on mainnet, and those four fixes are now the only thing between the client and a working transaction. INTERFACE carried the refund authorisation as an open question. It is decided, and the PRD has been reworded to match. INTERFACE also had no deployed addresses and no mention of pre-funding, both of which the client needs. It now opens with the mainnet and Sepolia addresses, marks the two Deposit positions that carry an optional pre-fund, and explains why the fee has to come from the escrow rather than the sender: funding the claimant directly would put the sender-to-recipient edge on chain, which is the one thing Xenia exists to hide.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Only .gitignore conflicted: main added .vercel while this branch added the Cairo build output and the CI log. Both belong, so both stay. PRD.md and strk20.json merged cleanly — strk20.json now carries the mainnet contract address from this branch alongside the demo URL from main.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
XeniaEscrow— contract, tests, tooling, and both deploymentsEverything on the contract-and-chain side, ready for the client to integrate against. No files
under
src/are touched, so this cannot conflict with the frontend work.The contract is live on mainnet
0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f566270950x7d01c97a95ddc117ac63be7a6ab4b042d87d8a70c1cadbdb1f4c1f88b68094eSame class hash on both, and identical to what the test suite runs against.
strk20.jsonnow liststhe mainnet address.
Verified on chain after deploying rather than assumed: the chain is
SN_MAIN,privacy_contract()returns the mainnet pool — the immutable setting that would have bricked every call had it been
wrong — and all four events are present in the deployed ABI.
What
XeniaEscrowdoes beyond the reference escrowwhich sits in public calldata and can be redirected by anyone who sees the transaction before it
is included. Xenia binds the authorisation to the claimant's address with a STARK-curve
signature, so a copied claim cannot be pointed somewhere else.
checks transactions for events from a listed contract.
26 tests, green in CI
test_xenia_escrow.cairotest_js_interop.cairotest_pool_handshake.cairoThat third suite is the one that matters for integration. The others fake the pool by cheating the
caller address; this one does the real handshake — raw
call_contract_syscallbyINVOKE_SELECTOR, positional deserialisation into ten parameters, a returnedSpan<OpenNoteDeposit>, and thetransfer_fromthat only works because the escrow approved first.If the client's calldata shape is right, these prove it works.
For the client
contracts/CLIENT-FIXES.md— four changes needed insrc/lib/xenia/, with the exact code. Nothing transacts until they land. Two of them are thePRD's fault, not the client's: §5.1/§5.2 showed six calldata elements against a ten-parameter
entrypoint. Both corrected in this PR.
contracts/INTERFACE.md— the frozen shape, calldata per operation,key derivation, events, errors, and how pre-funding works.
NEXT_PUBLIC_XENIA_ESCROWshould be the mainnet address above, or the Sepolia one for testing.What measuring mainnet established
Recorded in
contracts/ONCHAIN-FINDINGS.md, from the pool'ssource and its transaction history rather than from documentation:
rides alongside a
Deposit, never a pure receive.and pays no gas, but that reclaim is a
withdrawand the balance invariant demands a matchinginflow — which a first-time claimant has none of. A pure receive is refused by the protocol,
not by the wallet. Pre-funding exists for exactly this.
apply_actions, so the fallback istwo clicks on our own page rather than a trip into wallet settings.
/create.Tooling
contracts/scripts/— declare-and-deploy, deployer generation, pre-flight checks, a read-onlywallet probe, and the JavaScript reference generator. Built because
sncasthas no Windows binaryand will not compile on the contract machine; CI runs the suite on Linux.
The deploy script refuses to run on
SN_MAINunlessPOOL_ADDRESSmatches the verified mainnetpool and
CONFIRM_MAINNET=yesis set, because the constructor argument is immutable.Note on the three mainnet transactions
Now that
contractsis non-empty, each listed transaction must be tied to that address — thesprint team confirmed a plain shield will not count. So the three should be create-claim, claim
and refund, which makes the refund path demo-critical rather than a safety net.