Skip to content

XeniaEscrow — contract, tests, tooling, and both deployments - #1

Merged
Sam-Rytech merged 27 commits into
mainfrom
contracts/xenia-escrow
Aug 26, 2026
Merged

XeniaEscrow — contract, tests, tooling, and both deployments#1
Sam-Rytech merged 27 commits into
mainfrom
contracts/xenia-escrow

Conversation

@Sam-Rytech

Copy link
Copy Markdown
Collaborator

XeniaEscrow — contract, tests, tooling, and both deployments

Everything 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

Network Address
Mainnet 0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095
Sepolia 0x7d01c97a95ddc117ac63be7a6ab4b042d87d8a70c1cadbdb1f4c1f88b68094e

Same class hash on both, and identical to what the test suite runs against. strk20.json now lists
the 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 XeniaEscrow does beyond the reference escrow

  • Expiry and refund, so a lost link does not strand funds forever.
  • Link keys instead of bare secrets. The reference authorises a claim with a raw preimage,
    which 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.
  • An event on every state-changing path. The reference emits none, and the sprint validator
    checks transactions for events from a listed contract.
  • Optional pre-funding, so a claimant can arrive with an empty wallet.

26 tests, green in CI

Suite Covers
test_xenia_escrow.cairo deposit, claim, refund, expiry, double-spend, access control, signature binding
test_js_interop.cairo the client's JavaScript and the contract's Cairo agreeing — including a real browser signature verifying on chain
test_pool_handshake.cairo the whole lifecycle driven through a mock pool, using the exact flat calldata the client sends

That 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_syscall by
INVOKE_SELECTOR, positional deserialisation into ten parameters, a returned
Span<OpenNoteDeposit>, and the transfer_from that 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 in
    src/lib/xenia/, with the exact code. Nothing transacts until they land. Two of them are the
    PRD'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_ESCROW should be the mainnet address above, or the Sepolia one for testing.

What measuring mainnet established

Recorded in contracts/ONCHAIN-FINDINGS.md, from the pool's
source and its transaction history rather than from documentation:

  • Registration does bundle into a larger transaction in production — but every observed case
    rides alongside a Deposit, never a pure receive.
  • The fee is fronted by a relayer and reclaimed from the pool. The claimant needs no public STRK
    and pays no gas, but that reclaim is a withdraw and the balance invariant demands a matching
    inflow — 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.
  • Sponsorship is used by nobody: 18 of 18 note-bearing transactions were relayer-reimbursed.
  • A dapp can register a user itself through the pool's public apply_actions, so the fallback is
    two clicks on our own page rather than a trip into wallet settings.
  • The mainnet fee is 6 STRK per pool transaction — worth a minimum-amount guard on /create.

Tooling

contracts/scripts/ — declare-and-deploy, deployer generation, pre-flight checks, a read-only
wallet probe, and the JavaScript reference generator. Built because sncast has no Windows binary
and will not compile on the contract machine; CI runs the suite on Linux.

The deploy script refuses to run on SN_MAIN unless POOL_ADDRESS matches the verified mainnet
pool and CONFIRM_MAINNET=yes is set, because the constructor argument is immutable.

Note on the three mainnet transactions

Now that contracts is non-empty, each listed transaction must be tied to that address — the
sprint 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.

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.
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
xenia Ready Ready Preview Aug 26, 2026 12:15am

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 525b7dee-db8f-468d-b944-babcb0c89e65


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.
@Sam-Rytech
Sam-Rytech merged commit 6513c07 into main Aug 26, 2026
4 checks passed
@Sam-Rytech
Sam-Rytech deleted the contracts/xenia-escrow branch August 27, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant