Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
727464c
Add XeniaEscrow, its test suite, and contract CI
Sam-Rytech Aug 24, 2026
5ba958b
Run contract CI on every branch, not just main
Sam-Rytech Aug 24, 2026
0713a5b
Add a starknet.js declare-and-deploy script for XeniaEscrow
Sam-Rytech Aug 24, 2026
2df4cd3
Mirror OpenNoteDeposit locally instead of depending on the privacy pa…
Sam-Rytech Aug 24, 2026
5447dd5
Fix the test target so snforge actually collects the suite
Sam-Rytech Aug 24, 2026
6b85bbc
Write up the client fixes needed against XeniaEscrow
Sam-Rytech Aug 25, 2026
a80e214
Load deploy credentials from a gitignored .env
Sam-Rytech Aug 25, 2026
68b1c32
Add pre-flight checks for the deploying account
Sam-Rytech Aug 25, 2026
20b1e17
Support Argent v0.4 signatures in the deploy script, and fix the Acco…
Sam-Rytech Aug 25, 2026
5c9593f
Deploy XeniaEscrow to Sepolia and record the rehearsal
Sam-Rytech Aug 25, 2026
755c6ee
Answer two of the three open questions from mainnet itself
Sam-Rytech Aug 25, 2026
bd942b3
Settle the fee question from the pool's balance invariant
Sam-Rytech Aug 25, 2026
dabf729
Rule out sponsorship from measurement rather than assumption
Sam-Rytech Aug 25, 2026
793b28b
Add a read-only wallet probe page
Sam-Rytech Aug 25, 2026
3cd8771
Detect wallets that use the Wallet Standard, not just window injection
Sam-Rytech Aug 25, 2026
6be185e
Tell a Starknet wallet apart from any other registered wallet
Sam-Rytech Aug 25, 2026
3c5631f
Find wallets that install themselves as non-enumerable properties
Sam-Rytech Aug 25, 2026
c29a52c
Stop reading a payload complaint as an unimplemented method
Sam-Rytech Aug 25, 2026
710b4f9
Let a deposit pre-fund the claimant, without breaking the interface
Sam-Rytech Aug 25, 2026
eeccca2
Check that the client and the contract agree, in CI
Sam-Rytech Aug 25, 2026
2e6f252
Redeploy to Sepolia with the fee token and pre-funding
Sam-Rytech Aug 25, 2026
86c6b2f
Retry the build, and point the cache at our lock file
Sam-Rytech Aug 25, 2026
9b31c9f
Drive the whole lifecycle the way the pool drives it
Sam-Rytech Aug 25, 2026
d0bf9da
Deploy XeniaEscrow to mainnet
Sam-Rytech Aug 25, 2026
18abffd
Correct the PRD where it misled, and bring PROGRESS up to date
Sam-Rytech Aug 26, 2026
701249d
Bring the handover docs up to date before integration
Sam-Rytech Aug 26, 2026
b5f2c58
Merge main, keeping both sides of the ignore rules
Sam-Rytech Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: contracts

# Runs the Cairo suite on Linux, where snforge ships prebuilt binaries.
# starknet-foundry publishes no Windows build, and compiling it from source needs more RAM than
# the contract machine has — so this workflow is where `XeniaEscrow`'s tests actually get run.

on:
# Any branch, not just main — the contract machine cannot run snforge locally, so a push
# needs to produce test results without waiting on a pull request being opened.
push:
paths:
- 'contracts/**'
- '.github/workflows/contracts.yml'
pull_request:
paths:
- 'contracts/**'
- '.github/workflows/contracts.yml'
workflow_dispatch:

defaults:
run:
working-directory: contracts

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# The lock lives in contracts/, not at the repo root, so the action's cache cannot find it
# by default and reports "failed to find Scarb.lock" on every run.
- uses: software-mansion/setup-scarb@v1
with:
scarb-version: '2.20.1'
scarb-lock: contracts/Scarb.lock

- uses: foundry-rs/setup-snfoundry@v3
with:
starknet-foundry-version: '0.63.0'

- name: Versions
run: scarb --version && snforge --version

- name: Format
run: scarb fmt --check

# A commit touching only markdown once failed here while the identical contract code passed
# on the commit before it — a registry fetch hiccup, not a compile error. Retry rather than
# letting the network decide whether the suite runs.
- name: Build
run: |
for attempt in 1 2 3; do
if scarb build; then exit 0; fi
echo "::warning::scarb build failed on attempt $attempt, retrying"
sleep 10
done
exit 1

# `test_contracts` gates the mock ERC-20 the suite deploys. Without it the mock is not
# compiled and every test fails at `declare("MockERC20")`.
#
# The guard is not paranoia. A misconfigured `[[test]]` target once made snforge collect
# zero tests and exit 0, so the job went green having verified nothing. Collecting no tests
# is a failure, not a pass.
- name: Test
run: |
set -o pipefail
snforge test --features test_contracts 2>&1 | tee snforge.log
if grep -qE 'Collected 0 test|Tests: 0 passed' snforge.log; then
echo "::error::snforge collected no tests — the suite did not run"
exit 1
fi
echo "Collected: $(grep -oE 'Collected [0-9]+ test' snforge.log | head -1)"
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,13 @@ build/
.next/
next-env.d.ts
tsconfig.tsbuildinfo

# Cairo build output
contracts/target/
.snfoundry_cache/

# snforge log written by CI
contracts/snforge.log

# vercel
.vercel
112 changes: 83 additions & 29 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,38 @@ The interface in §4 is frozen. Both sides build against it independently.

---

## 0. UrgentSam, these three before you write the claim branch
## 0. Blockerssettled, 26 Aug

**0.1 Ask the sprint team for the mainnet proving service URL. Today.**
It is not published. The Wallet API route needs only an RPC URL because the user's wallet reaches a
prover itself; the SDK route means we reach it, and on mainnet that endpoint does not exist
publicly. The sprint's own Day-0 doc says teams that need it should open an issue and ask, and
calls it "the one blocker a team cannot work around on its own." We may never use it — but if the
wallet probe fails on day 5 and we have not asked, the fallback is gone. Ask now, regardless of
what the probe says. Telegram escalation is yours.
All three of these are now answered, two of them by measuring mainnet rather than waiting on a
reply. Full working in `contracts/ONCHAIN-FINDINGS.md`.

**0.2 `XeniaEscrow` must emit an event on every state-changing path.**
The sprint validator requires that if `contracts` is non-empty, each transaction listed in
`strk20.json` also carries an event emitted by one of those contracts. The reference escrow emits
nothing. A straight port gives us three valid-looking mainnet transactions that fail validation and
score as if we never shipped. Events are specified in §4.3. Not polish.
**0.1 The mainnet proving service URL — not needed.**
It is only required on the SDK route. The Wallet API route is the one route that needs no proving
service of your own, and Ready reports Wallet API `0.10.3`, which is the version carrying STRK20.
The SDK route is dead on mainnet anyway: no public prover, and `ContractDiscoveryProvider` is not
exported in `0.14.3-rc.5`, so it would also need a hosted indexer.

**0.3 The mainnet pool address is confirmed — the one in the STRK20 docs is Sepolia.**
Verified values are in §5.4. Do not deploy or test against the docs address.
**0.2 `XeniaEscrow` emits on every state-changing path.** Done — `ClaimCreated`, `ClaimRedeemed`,
`ClaimRefunded`, plus `ClaimPrefunded`. Confirmed present in the deployed mainnet ABI.

Also read §4.1 (the frozen signature) and §4.5 (link keypair instead of a bare secret) before the
claim branch — §4.5 changes the calldata.
**0.3 The mainnet pool is confirmed** and the contract is deployed against it. Values in §5.4.

---
### What measuring mainnet also established

- **Registration does bundle into a larger transaction**, in production today — but every observed
case rides alongside a `Deposit`. Never a pure receive, which is our shape.
- **The fee is fronted by a relayer and reclaimed from the pool.** The claimant needs no public
STRK and no allowance, and pays no gas — but the reimbursement is a `withdraw`, and the pool's
balance invariant requires an inflow to match it. A first-time claimant has none, so a pure
receive is refused **by the protocol, not by the wallet**.
- **Sponsorship is not in use.** 18 of 18 note-bearing transactions were relayer-reimbursed; none
sponsored, none self-paid.
- **A dapp can register a user itself** — registration is a plain call to the pool's public
`apply_actions`. The fallback is two clicks on our page, never a trip into wallet settings.

Consequence: a claim needs an inflow covering the fee. `XeniaEscrow` supports pre-funding the
claimant out of the escrow for exactly this, opt-in, using two Deposit parameters that were
previously zero — so the calldata shape does not change.

## 1. Product

Expand Down Expand Up @@ -67,7 +76,7 @@ The mainnet weight is mechanical and most of the field fails it. It comes first.

| Field | Requirement |
|---|---|
| `transactions` | ≥3 mainnet hashes. Each must exist, have succeeded, and have touched the STRK20 pool |
| `transactions` | ≥3 mainnet hashes. Each must exist, have succeeded, and be **tied to a listed contract** — see below |
| `contracts` | `XeniaEscrow`'s mainnet address |
| `demo_url` | Public, no login wall |
| `demo_video` | 3 minutes |
Expand All @@ -78,6 +87,14 @@ The three transactions:
2. **Create claim** — invokes `XeniaEscrow`, funds park in the helper
3. **Claim** — from an account that has never registered: register and claim, one transaction

> **Confirmed 26 Aug.** The sprint team put it plainly: *"If contracts is non-empty, a plain shield
> won't count. Each listed tx must succeed, emit a pool event, and be tied to one of the declared
> contracts. The checker accepts either an event from that contract or its address in calldata."*
>
> So the three listed transactions must be **create-claim, claim and refund** — all of which run
> through `XeniaEscrow` and emit its events. The shield still happens; it just cannot be one of the
> three. This makes the refund path demo-critical rather than a safety net.
>
> **Listing a contract raises the bar on every transaction.** The sprint validator requires that if
> `contracts` is non-empty, each listed transaction must also carry an **event emitted by one of
> those contracts**. Touching the pool through someone else's contract does not count as your
Expand Down Expand Up @@ -199,8 +216,20 @@ Indexing `commitment` also gives the client a free way to read claim status with
4. Claim recomputes the commitment from the link key. It never trusts a passed-in commitment as
authorisation.
5. `claimed` flips exactly once. A second claim reverts; a refund after a claim reverts.
6. Claim requires `get_block_timestamp() < expiry`. Refund requires `>= expiry` **and** a caller
matching `refund_to`.
6. Claim requires `get_block_timestamp() < expiry`. Refund requires `>= expiry` **and a signature
under `XENIA_REFUND_V1`**.

> **Corrected 26 Aug.** This originally said refund requires "a caller matching `refund_to`".
> That cannot be implemented. `privacy_invoke` is always called *by the pool*, so
> `get_caller_address()` is the pool's address on every path — and mainnet traces confirm
> private transactions are submitted by rotating relayers, so even the transaction sender is not
> the user. The assert would have rejected every refund ever made.
>
> Refund is therefore authorised the way a claim is, by proving possession of the link key,
> under its own domain tag so the two can never be replayed for each other. `refund_to` is
> stored and emitted for the `/claims` UI but gates nothing. After expiry anyone holding the
> link can sweep it — they could have claimed it before expiry anyway, so this grants no new
> capability. See `contracts/INTERFACE.md`.
7. Domain-separated hashing throughout, so Xenia commitments cannot collide with anything else.
8. The escrow `approve`s the pool to pull and returns an `OpenNoteDeposit`. It never transfers
tokens directly.
Expand Down Expand Up @@ -256,7 +285,7 @@ Ship with these passing:
- claim with a signature over a different address reverts `BAD_SIGNATURE`
- claim after expiry reverts `CLAIM_EXPIRED`
- refund before expiry reverts `NOT_YET_EXPIRED`
- refund by anyone other than `refund_to` reverts `NOT_REFUND_OWNER`
- refund not signed by the link key reverts `NOT_REFUND_OWNER` (see the note in §4.4.6)
- refund after a claim reverts `ALREADY_CLAIMED`
- a caller that is not the pool reverts `CALLER_NOT_PRIVACY`
- every successful path emits its event
Expand All @@ -269,10 +298,19 @@ Action list, in phase order:

```
{ type: 'withdraw', token, amount, recipient: XENIA_ESCROW }
{ type: 'invoke', contract: XENIA_ESCROW, calldata: [Deposit, commitment, token,
amount, expiry, refund_to] }
{ type: 'invoke', contract: XENIA_ESCROW,
calldata: [Deposit, commitment, token, amount, expiry, refund_to, 0, 0, 0, 0] }
```

> **Corrected 26 Aug — this is where the client went wrong.** These lists previously showed six
> calldata elements. §4.1 freezes a **ten**-parameter entrypoint, and the pool forwards calldata
> unchanged for Starknet to deserialise positionally, so every operation must send all ten with
> unused positions as `0`. Six felts against ten parameters fails before our code runs. Verified
> against the pool's own source and covered by `tests/test_pool_handshake.cairo`.
>
> Note also that Claim and Refund pass the link **public key** in position 1, not the commitment —
> the contract hashes it and looks that up itself.

The withdraw settles the pool's balance invariant, which is why the escrow returns an empty span.
The link is `https://<host>/c#<sk>` — the key lives in the URL fragment and is never sent to a
server.
Expand All @@ -289,7 +327,7 @@ server.
action with amount `OPEN`. The amount is measured at execution, which is how the open note gets
credited with a value the client never states.

### 5.3 Claim route — settle this on Day 1, before any UI
### 5.3 Claim route — settled: Wallet API

Registration is phase 0 and the invoke is phase 7, so the protocol permits register-and-claim in
one transaction. The route does not obviously permit it.
Expand All @@ -303,6 +341,16 @@ and `autoRegister` is an **SDK** flag, not a wallet one.
So the Wallet API route works only if the connected wallet registers the account itself while
assembling the transaction.

**Settled 26 Aug: the Wallet API route.** Ready reports Wallet API `0.10.3`, the version carrying
STRK20 (`contracts/scripts/probe.html` checks this against a live wallet). The SDK route is not
viable on mainnet — no public prover, and discovery would need a hosted indexer.

What is **still unproven** is whether Ready emits a phase-0 registration for our exact shape:
`transfer("OPEN") + invoke` with no deposit. Mainnet shows registration bundling only alongside a
`Deposit`. Support advises not relying on first-use registration through a dapp call. Plan for
two-step and be pleased if it folds in — and note that a claim carrying pre-funding *is* a shape
with money going in, which is the shape that has been observed working.

**Probe first, build second.** Connect an account that has never registered and submit a
claim-shaped transaction on testnet.

Expand Down Expand Up @@ -334,6 +382,12 @@ support does:
- **SDK route** means we reach the proving service, so we need its URL, and on mainnet it does not
exist publicly yet. Teams that need it are told to open an issue and ask.

**The pool fee, measured live 26 Aug:** `get_fee_amount()` is **6 STRK on mainnet**, 2 on Sepolia,
charged per pool transaction. The relayer fronts it and reclaims it from the pool, so the claimant
needs no public STRK and pays no gas — but that reclaim is a `withdraw`, and the balance invariant
demands a matching inflow. Size demo claims well above 6 STRK; a fee that large against a small
claim reads badly on video.

Registering a viewing key and shielding need **no proof at all** — both are ordinary public
transactions. Spending notes privately is what needs a prover, which is why the claim transaction
does and transaction 1 does not.
Expand Down Expand Up @@ -385,10 +439,10 @@ do something about it. If another team depends on it, that counts in our favour.

## 7. Division

| Owner | Surface |
|---|---|
| Sam | `XeniaEscrow`, tests, testnet and mainnet deploys, calldata shape, the three transactions, `strk20.json` |
| Jadon | Client, claim flow, link and key generation, pages, Vercel, README, leak table, video |
| Owner | Surface | State |
|---|---|---|
| Sam | `XeniaEscrow`, tests, testnet and mainnet deploys, calldata shape, the three transactions, `strk20.json` | Contract **done**: 26 tests green, deployed and verified on Sepolia and mainnet, `contracts` field filled. Transactions await the client. |
| Jadon | Client, claim flow, link and key generation, pages, Vercel, README, leak table, video | Four blocking defects in `contracts/CLIENT-FIXES.md` — nothing transacts until they land |

Interface frozen Day 1 (§4.1). The client builds against a stub helper on testnet until the real
one is deployed.
Expand Down
49 changes: 48 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,34 @@

**Deadline: Aug 31, 23:59 UTC.** Nothing to submit — whatever the repo shows at that moment is the entry.

Today is **Aug 23**. **8 days.**
Today is **Aug 26**. **5 days.**

## Where it stands

**Contract and chain (Sam) — done.**

| | |
|---|---|
| `XeniaEscrow` | 26 tests green in CI |
| Mainnet | `0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095` |
| Sepolia | `0x7d01c97a95ddc117ac63be7a6ab4b042d87d8a70c1cadbdb1f4c1f88b68094e` |
| `strk20.json` | `contracts` filled |
| Deployer | 15.99 STRK left for the demo transactions |

Tests cover the escrow's logic, cross-language agreement with the client's JavaScript (a real
browser signature verifying in Cairo), and the full lifecycle driven through a mock pool using the
exact flat calldata the client will send.

**Client (Jadon) — blocked, and unaware.** Four defects in `contracts/CLIENT-FIXES.md`; nothing
transacts until they land. Two of them are the PRD's fault, since §5.1/§5.2 showed six calldata
elements against a ten-parameter entrypoint. Both now corrected.

**The three mainnet transactions — not started.** They are the pass/fail requirement and they run
entirely through the client. Because `contracts` is now non-empty, they must be **create-claim,
claim and refund**; a plain shield does not count.

**Still unproven:** whether Ready folds registration into our claim shape. Needs a browser, a
working client, and the untouched Sepolia account.

---

Expand Down Expand Up @@ -136,3 +163,23 @@ Treat **Aug 30, 23:59** as the real deadline so this day is spare.
- **Aug 23** — Kharon abandoned. Its premise (nobody can pay gas for an unshield) was wrong: paymaster relaying is first-class in STRK20 and AVNU already ships it. Verified against the docs before committing.
- **Aug 23** — Xenia chosen: claim-link payments to unregistered recipients. StarkWare documented the gap and published an explicitly unofficial, unaudited sketch with no SDK support. Phase ordering confirms register-and-claim fits in one transaction.
- **Aug 23** — Roles: Sam on contract + chain, teammate on client + delivery.

- **Aug 24** — Contract scaffolded against the frozen §4.1 interface. `sncast` and `snforge` have
no Windows binaries and will not build here, so tests run in CI and deploys go through a
starknet.js script instead.
- **Aug 25** — Sepolia rehearsal. It caught four things that would each have cost a mainnet
attempt: starknet.js v10 replaced the positional `Account` constructor, Argent v0.4 rejects a
bare `[r, s]`, an account with a guardian cannot be scripted at all, and several public RPCs are
dead or fail on `estimateFee`.
- **Aug 25** — §4.4.6's refund check found unimplementable: `privacy_invoke` is always called by
the pool, so `get_caller_address()` can never be the sender. Refund is authorised by a signature
under its own domain tag instead.
- **Aug 25** — Answered the open questions by measuring mainnet rather than waiting: registration
does bundle but only alongside a deposit; the fee is relayer-fronted and reclaimed from the pool;
a dapp can register a user itself via `apply_actions`; sponsorship is used by nobody, 0 of 18.
- **Aug 25** — The pool's balance invariant makes a zero-balance claim impossible at the protocol
level, not the wallet level. `XeniaEscrow` gained opt-in pre-funding, paid out of the escrow
rather than the sender's address so no sender-to-recipient edge appears on chain. Two Deposit
parameters that were already zero carry it, so the calldata shape did not move.
- **Aug 26** — Deployed to mainnet and verified on chain. Cost ~10.3 STRK; the declare reserves a
ceiling near 21 before it will run.
Loading
Loading