diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..f8c1687 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -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)" diff --git a/.gitignore b/.gitignore index 2425c2d..84471e5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/PRD.md b/PRD.md index 850ffc0..8dcd01e 100644 --- a/PRD.md +++ b/PRD.md @@ -7,29 +7,38 @@ The interface in §4 is frozen. Both sides build against it independently. --- -## 0. Urgent — Sam, these three before you write the claim branch +## 0. Blockers — settled, 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 @@ -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 | @@ -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 @@ -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. @@ -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 @@ -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:///c#` — the key lives in the URL fragment and is never sent to a server. @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/PROGRESS.md b/PROGRESS.md index 7e9d361..8c80d2d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. --- @@ -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. diff --git a/contracts/CLIENT-FIXES.md b/contracts/CLIENT-FIXES.md new file mode 100644 index 0000000..bb7eed3 --- /dev/null +++ b/contracts/CLIENT-FIXES.md @@ -0,0 +1,157 @@ +# Client fixes needed against `XeniaEscrow` + +Jadon — four changes, all in `src/lib/xenia/`. Until these land, every claim reverts. Sorry: two +of them are the PRD's fault, not yours, and I've noted which. + +**The contract is live on mainnet** at +`0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095` (Sepolia: +`0x7d01c97a95ddc117ac63be7a6ab4b042d87d8a70c1cadbdb1f4c1f88b68094e`), so these are the only thing +standing between the client and a working transaction. + +Cross-check against [`INTERFACE.md`](INTERFACE.md), which is the frozen shape. + +--- + +## 1. Calldata must be 10 elements on every operation + +**This one is the PRD's fault.** §4.1 freezes a 10-parameter signature, but §5.1/§5.2 show +6-element calldata. You built to §5, which was the reasonable thing to do. §5 is wrong and I am +correcting it. + +I verified this against the pool's own source (`packages/privacy/src/actions.cairo`): + +```cairo +pub struct InvokeExternalInput { + pub contract_address: ContractAddress, + pub calldata: Span, +} +``` + +The pool forwards that span to our entrypoint **unchanged**, and Starknet deserialises it +positionally into the function's parameters. Send 6 felts to a 10-parameter entrypoint and it fails +before our code runs. Unused positions are `0`. + +`actions.ts` — `createClaimActions`: + +```js +calldata: [ + OPERATION.Deposit, + p.commitment, + p.token, + p.amount, + `0x${p.expiry.toString(16)}`, + p.refundTo, + '0x0', // claimant — unused on Deposit + '0x0', // sig_r + '0x0', // sig_s + '0x0', // note_id +], +``` + +`claimActions`: + +```js +calldata: [ + OPERATION.Claim, + p.pk, // the PUBLIC KEY, not the commitment — see §3 below + '0x0', // token — unused on Claim, the stored entry wins + '0x0', // amount + '0x0', // expiry + '0x0', // refund_to + p.claimant, + p.signature.r, + p.signature.s, + FIRST_OPEN_NOTE, +], +``` + +`refundActions`: + +```js +calldata: [ + OPERATION.Refund, + p.pk, + '0x0', '0x0', '0x0', '0x0', + p.refundTo, // the address the refund signature authorises + p.signature.r, + p.signature.s, + FIRST_OPEN_NOTE, +], +``` + +`FIRST_OPEN_NOTE` and `'OPEN'` stay literal strings — the wallet substitutes them. Never +`num.toHex` those. + +## 2. Refund needs a signature + +**Also the PRD's fault.** §4.4.6 says refund is authorised by "a caller matching `refund_to`". +That cannot be implemented: `privacy_invoke` is always called *by the pool*, so +`get_caller_address()` is the pool on every path — and support confirmed private transactions are +submitted by rotating relayers besides, so even the transaction sender is not the user. There is no +way for the contract to learn who initiated a refund. + +Refund is therefore authorised the same 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. + +`crypto.ts`: + +```ts +export const REFUND_TAG = shortString.encodeShortString('XENIA_REFUND_V1'); + +/** The message a refunder signs. Same shape as claimMessage, different tag. */ +export function refundMessage(commitment: string, refunder: string): string { + return hexOf( + hash.computePoseidonHashOnElements([REFUND_TAG, hexOf(commitment), hexOf(refunder)]), + ); +} + +export function signRefund(sk: string, commitment: string, refunder: string): ClaimSignature { + const signature = ec.starkCurve.sign(refundMessage(commitment, refunder), hexOf(sk)); + return { r: hexOf(signature.r), s: hexOf(signature.s) }; +} +``` + +`RefundParams` needs a `signature: ClaimSignature`. The sender generated `sk`, so the sender can +always sign — but note the consequence: `refund_to` is **display metadata, not an access check**. +After expiry, anyone holding the link can sweep it. They could have claimed it before expiry +anyway, so it grants no new capability, and it is consistent with the README's bearer-instrument +row. Worth a line in the refund UI. + +## 3. `commitment` vs `pk` — the easy one to get wrong + +Your `claimActions` already does this correctly; flagging it so it survives a refactor. + +- **Deposit** passes the *hash*: `poseidon(COMMITMENT_TAG, pk)`. +- **Claim and Refund** pass the raw **public key**. The contract hashes it itself and looks that up, + so a passed-in commitment is never trusted as authorisation. + +Passing the hash on a claim finds nothing and reverts `COMMITMENT_NOT_FOUND`. + +## 4. Read entrypoint is `get_claim`, not `claim_of` + +`escrow.ts` line 40: + +```ts +entrypoint: 'get_claim', +``` + +Returns `ClaimEntry` in declaration order — `token, amount, expiry, refund_to, claimed` — which +matches your positional read. Zero `token` means not found. + +--- + +## Not a fix — the tags now match + +`XENIA_COMMITMENT_V1` and `XENIA_CLAIM_V1` were mine to move, and I moved the contract to your +strings rather than the other way round. They agree now. You only need to add `XENIA_REFUND_V1`. + +## Confirmed since the PRD was written + +- **Our create-claim shape is valid.** `withdraw → invoke` with the helper returning an empty span + is legal; no `OPEN` transfer is needed on that leg. It must be a properly ABI-encoded empty span, + which is what the contract returns. +- **The mainnet pool fee is 6 STRK per pool transaction.** Claims well below that will look absurd. + Worth a minimum-amount guard on `/create`. +- **All three listed mainnet transactions must be tied to `XeniaEscrow`** — a plain shield does not + count. So the three will be create-claim, claim, refund, which makes the refund path + demo-critical rather than a safety net. diff --git a/contracts/DEPLOYMENTS.md b/contracts/DEPLOYMENTS.md new file mode 100644 index 0000000..c7a986e --- /dev/null +++ b/contracts/DEPLOYMENTS.md @@ -0,0 +1,84 @@ +# Deployments + +## Mainnet — 2026-08-26 + +| | | +|---|---| +| `XeniaEscrow` | `0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095` | +| Class hash | `0x65651460529d1b5d02ee24e7038dfa47df038cd5b7788aebd65fd5c2e07dfc5` | +| Pool | `0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a` | +| Fee token (STRK) | `0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d` | +| Deploy tx | `0x44b7936025f8b82828c3ecc97e169d6544f849313fba87f29cc8de8582a8194` | +| Cost | ~10.3 STRK (declare + deploy) | + +Verified after deploying: chain is `SN_MAIN`, `privacy_contract()` returns the mainnet pool, +`get_claim` on an unknown commitment returns the all-zero sentinel, and all four events are in the +ABI. The class hash is identical to the Sepolia deployment, so the code that passed the suite is +exactly what is on mainnet. + +The declare reserves a ceiling of roughly 21 STRK before it will run, even though it charges about +half that. Fund the deployer above the ceiling, not above the expected cost. + +**This address goes in `strk20.json` and `NEXT_PUBLIC_XENIA_ESCROW`.** + + +## Sepolia — 2026-08-26 (current) + +Redeployed after the constructor gained a fee token and Deposit gained optional pre-funding. + +| | | +|---|---| +| `XeniaEscrow` | `0x7d01c97a95ddc117ac63be7a6ab4b042d87d8a70c1cadbdb1f4c1f88b68094e` | +| Class hash | `0x65651460529d1b5d02ee24e7038dfa47df038cd5b7788aebd65fd5c2e07dfc5` | +| Pool | `0x0254a6b2997ef52e9f830ce1f543f6b29768295e8d17e2267d672c552cfe0d91` | +| Fee token (STRK) | `0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d` | +| Deploy tx | `0x27a586c3005752b3c35edce8a0c9c6438eb587e92aadfd5d9eeef800e9d5f57` | + +Verified live: `privacy_contract()` returns the Sepolia pool, `get_claim` on an unknown commitment +returns the all-zero sentinel, and the deployed ABI carries all four events including +`ClaimPrefunded`. + +**Client should point at this address**, not the one below. + +## Sepolia — 2026-08-25 (superseded) + +Rehearsal for mainnet. Everything below was verified on-chain after deploying, not just assumed +from a script exiting zero. + +| | | +|---|---| +| `XeniaEscrow` | `0x4564195cae51bab74923df3029c43a4f27149b361488235c7e3ff1ea1374b81` | +| Class hash | `0x448eddda01dc06623793bc0828c39781f58a4cb1d2c5b931705b11be1acd764` | +| Pool (constructor arg) | `0x0254a6b2997ef52e9f830ce1f543f6b29768295e8d17e2267d672c552cfe0d91` | +| Declare tx | `0x533115db6b8849a9a09a784773b2d4961cded3c94edc68717fb628cb5cf7e0e` | +| Deploy tx | `0x183fe8a11bc6a1b5ad7263350d1a590b7ab5d1673ac8bf706856087608c4673` | +| RPC used | `https://api.zan.top/public/starknet-sepolia` | +| Cost | ~0.06 STRK | + +Post-deploy checks: `privacy_contract()` returns the Sepolia pool, and `get_claim` on an unknown +commitment returns the all-zero not-found sentinel. + +## What the rehearsal caught + +None of these were visible from a dry run, and each would have cost a mainnet attempt: + +1. **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. +2. **Argent/Ready v0.4 accounts reject a bare `[r, s]`.** They validate against + `Array`; a single Starknet owner encodes as five felts. +3. **An account with a guardian cannot be scripted at all.** Argent Shield requires the owner *and* + the guardian to sign, and the guardian key is held by Argent. The fix is a dedicated + OpenZeppelin deployer account, which is better practice for a deploy key anyway. +4. **Some public RPCs fail on `starknet_estimateFee`** with an opaque `-32603 Internal error`. + Blast is retired, Lava's testnet endpoint errors, and cartridge failed to estimate a declare. + +## Mainnet checklist + +- [ ] Generate a **fresh** deployer with `new-deployer.mjs --generate`; never paste its key anywhere +- [ ] Fund it with just enough STRK +- [ ] `POOL_ADDRESS=0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a` and + `CONFIRM_MAINNET=yes` — the deploy script refuses any other pool on `SN_MAIN` +- [ ] `npm run preflight`, then `npm run deploy:dry`, then `npm run deploy` +- [ ] Verify `privacy_contract()` returns the **mainnet** pool before doing anything else +- [ ] Record the address in `strk20.json` and `NEXT_PUBLIC_XENIA_ESCROW` diff --git a/contracts/INTERFACE.md b/contracts/INTERFACE.md new file mode 100644 index 0000000..c2eb96d --- /dev/null +++ b/contracts/INTERFACE.md @@ -0,0 +1,203 @@ +# `XeniaEscrow` — frozen interface + +Frozen Day 1 (2026-08-24). The client builds against this document. Any change lands **here first**, +and both people are told before either pushes. + +Source of truth for the shape: [`src/xenia_escrow.cairo`](src/xenia_escrow.cairo). This file +explains how to drive it. + +## Deployed + +| Network | Address | +|---|---| +| **Mainnet** | `0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095` | +| Sepolia | `0x7d01c97a95ddc117ac63be7a6ab4b042d87d8a70c1cadbdb1f4c1f88b68094e` | + +Same class hash on both, so behaviour is identical. Set `NEXT_PUBLIC_XENIA_ESCROW` to whichever +network you are pointing at. Full detail in [`DEPLOYMENTS.md`](DEPLOYMENTS.md). + +--- + +## Entry point + +```cairo +fn privacy_invoke( + ref self: ContractState, + operation: XeniaOperation, // 0 = Deposit, 1 = Claim, 2 = Refund + commitment: felt252, + token: ContractAddress, + amount: u128, + expiry: u64, + refund_to: ContractAddress, + claimant: ContractAddress, + sig_r: felt252, + sig_s: felt252, + note_id: felt252, +) -> Span; +``` + +The pool deserialises calldata straight into these parameters. **Every parameter is always +present**, in this order, on every operation — unused ones are passed as `0`. The enum serialises +as a single felt discriminant. + +Verified against the built ABI in `target/dev/xenia_XeniaEscrow.contract_class.json`. + +## Calldata by operation + +| Position | Field | Deposit | Claim | Refund | +|---:|---|---|---|---| +| 0 | `operation` | `0` | `1` | `2` | +| 1 | `commitment` | `poseidon(TAG, pk)` | **`pk`** | **`pk`** | +| 2 | `token` | token address | `0` | `0` | +| 3 | `amount` | `u128` | `0` | `0` | +| 4 | `expiry` | absolute unix ts | `0` | `0` | +| 5 | `refund_to` | sender's address | `0` | `0` | +| 6 | `claimant` | `0`, or an address to pre-fund | claimant address | refunder address | +| 7 | `sig_r` | `0` | signature r | signature r | +| 8 | `sig_s` | `0` | signature s | signature s | +| 9 | `note_id` | `0`, or the amount to pre-fund | `${openNoteIds[0]}` | `${openNoteIds[0]}` | + +> **Pre-funding is optional.** Positions 6 and 9 were unused on Deposit, so they now carry an +> address to pre-fund and how much, and **zero in either keeps the old behaviour exactly**. They +> were reused rather than appended because the pool deserialises calldata positionally — appending +> would change the length and break every caller. See "Pre-funding a claimant" below. + +> **Row 1 is the easy mistake.** On Deposit you pass the *hash*. On Claim and Refund you pass the +> *public key* — the contract hashes it itself and looks that up. Passing the hash on a claim finds +> nothing and reverts `COMMITMENT_NOT_FOUND`. + +## Key derivation — client side + +``` +sk = random felt (CSPRNG, reduced into the STARK field) +pk = stark_curve_public_key(sk) +commitment = poseidon([ 'XENIA_COMMITMENT_V1', pk ]) +``` + +The link is `https:///c#` — the fragment is never sent to a server. + +To claim, sign with `sk`: + +``` +message = poseidon([ 'XENIA_CLAIM_V1', commitment, claimant ]) +(r, s) = sign(message, sk) +``` + +To refund, the same but under a different tag: + +``` +message = poseidon([ 'XENIA_REFUND_V1', commitment, refunder ]) +``` + +`commitment` in both messages is the **hash**, not `pk`. The tags are Cairo short strings; in JS +use `encodeShortString('XENIA_CLAIM_V1')` and Poseidon over the felt array. + +## Action lists + +**Create a claim** (PRD §5.1): + +```js +{ type: 'withdraw', token, amount, recipient: XENIA_ESCROW } +{ type: 'invoke', contract: XENIA_ESCROW, + calldata: [0, commitment, token, amount, expiry, refundTo, 0, 0, 0, 0] } +``` + +**Claim** (PRD §5.2): + +```js +{ type: 'transfer', token, amount: 'OPEN', recipient: claimant } +{ type: 'invoke', contract: XENIA_ESCROW, + calldata: [1, pk, 0, 0, 0, 0, claimant, sigR, sigS, '${openNoteIds[0]}'] } +``` + +**Refund** — identical to claim with `operation = 2` and the refund-tagged signature. + +Dry-run every calldata change with `strk20PrepareInvoke` before submitting. + +## Events + +Indexed on `commitment` (the hash), so `/claims` can read status with no server. + +| Event | Emitted by | Fields | +|---|---|---| +| `ClaimCreated` | Deposit | `commitment` (key), `token`, `amount`, `expiry` | +| `ClaimRedeemed` | Claim | `commitment` (key), `claimant`, `amount` | +| `ClaimRefunded` | Refund | `commitment` (key), `refund_to`, `amount` | + +A claim is outstanding if it has a `ClaimCreated` and neither of the other two. + +## Errors + +`ZERO_COMMITMENT`, `ZERO_TOKEN`, `ZERO_AMOUNT`, `EXPIRY_IN_PAST`, `COMMITMENT_EXISTS`, +`COMMITMENT_NOT_FOUND`, `ALREADY_CLAIMED`, `CLAIM_EXPIRED`, `NOT_YET_EXPIRED`, `NOT_REFUND_OWNER`, +`BAD_SIGNATURE`, `CALLER_NOT_PRIVACY`. + +## Read-only helpers + +```cairo +fn get_claim(commitment: felt252) -> ClaimEntry; // keyed by the HASH; zero token = not found +fn privacy_contract() -> ContractAddress; +``` + +--- + +## Deviation from PRD §4.4.6 — refund authorisation + +**PRD §4.4 invariant 6 says refund requires "a caller matching `refund_to`". That check cannot be +implemented as written, and the contract does something else.** + +`privacy_invoke` is always called *by the privacy pool*, so `get_caller_address()` is the pool's +address on every path, including refund. The sender's own address never reaches the contract — +that is the entire point of routing through the pool. A `get_caller_address() == refund_to` assert +would reject every refund that has ever been made. + +What the contract does instead: **refund is authorised by a signature under +`XENIA_REFUND_TAG_V1`**, post-expiry, and reverts `NOT_REFUND_OWNER` when it fails. The sender +generated `sk`, so the sender can always sign. Consequences worth being deliberate about: + +- **`refund_to` is metadata, not an access check.** It is stored, emitted in `ClaimRefunded`, and + useful for the `/claims` UI — but it does not gate anything. The `OpenNoteDeposit` credits the + note in the submitting transaction, so the contract cannot force funds to a specific address + even if it wanted to. +- **After expiry, anyone holding the link can sweep it** — but they could have claimed it before + expiry anyway, so this grants no capability they did not already have. Consistent with the + README's bearer-instrument row. +- The separate domain tag means a claim signature can never be replayed as a refund, or vice versa. + +The alternative — a direct ERC-20 transfer to `refund_to` with an empty span — *is* enforceable, +but it publishes the sender's address next to the escrow and contradicts ARCHITECTURE §4, which +specifies the refund credits "the sender's own open note". + +**Decided 26 Aug: the signature approach stands.** PRD §4.4.6 and the §4.7 test have been reworded +to match. Mainnet traces settled it — private transactions are submitted by rotating relayers, so +even the transaction sender is not the user, and no caller-based check could ever work. + +--- + +## Pre-funding a claimant + +A first-time claimant cannot pay the pool fee. The pool charges 6 STRK per transaction on mainnet, +the relayer fronts it and reclaims it with a `withdraw`, and the pool's balance invariant requires +an inflow to match that withdrawal. Someone holding nothing inside the pool has none, so the +transaction is refused — **by the protocol, not by the wallet**. Working in +[`ONCHAIN-FINDINGS.md`](ONCHAIN-FINDINGS.md). + +So a deposit can send the claimant the fee ahead of time: + +```js +// create-claim, with pre-funding +calldata: [0, commitment, token, amount, expiry, refundTo, prefundTo, 0, 0, prefundAmount] +``` + +Two things to get right: + +- **The escrow needs the fee token to send.** Add a second `withdraw` action for STRK alongside the + claim token's, both to `XENIA_ESCROW`. A claim denominated in USDC still owes its fee in STRK. +- **Pay out of the escrow, never from the sender's own address.** Had the sender funded the + claimant directly, the trail sender → claimant would be public and would expose exactly the edge + Xenia exists to hide. Coming from a shared contract leaks nothing. + +`ClaimPrefunded { commitment, recipient, amount }` is emitted when it happens. It is a separate +event so `ClaimCreated` keeps the shape you already read. + +Zero in either field skips all of this, so links work with or without it. diff --git a/contracts/ONCHAIN-FINDINGS.md b/contracts/ONCHAIN-FINDINGS.md new file mode 100644 index 0000000..6f42101 --- /dev/null +++ b/contracts/ONCHAIN-FINDINGS.md @@ -0,0 +1,184 @@ +# What mainnet actually does — measured, 2026-08-25 + +Three questions were sitting with support. Two of them turned out to be answerable by reading +mainnet itself, and the answers are better than the guidance suggested. Method: pull every +`ViewingKeySet` event the pool has emitted, fetch each transaction, and look at what else happened +inside it. + +Pool `0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a`, last 50 000 blocks, +12 registrations found. + +--- + +## 1. Registration DOES bundle into a larger transaction — with a deposit + +Of the registrations examined, the split is clean: + +| Shape | Count | +|---|---| +| `ViewingKeySet + Deposit + EncNoteCreated + Withdrawal` | 5 | +| `ViewingKeySet` alone | 3 | + +**Every bundled registration rides alongside a `Deposit`. Not one is a pure receive.** + +So the protocol and the wallets both permit folding registration into a real transaction — that is +settled, and it is happening in production today. What remains unproven is Xenia's exact shape: +`transfer("OPEN") + invoke` with **no** deposit. No such transaction exists on mainnet to point at. + +This is exactly the (a)/(b) distinction we put to support, and the evidence says (a) works. + +## 2. The fee is paid publicly by a relayer, then reclaimed from the pool + +Traced through `0x15788481aee3…`, following every STRK transfer: + +``` +6.0000 relayer → paymaster (relayer fronts the fee) +6.0000 paymaster → fee_collector ← the 6 STRK pool fee +8.0000 user → pool ← the user's deposit +6.0000 pool → paymaster ← the pool reimburses the relayer +``` + +The transaction's `sender_address` is a relayer, not the user, which is why the claimant needs no +public STRK and no allowance — support was right about that. + +**But the reimbursement comes out of the pool.** In this transaction the user deposited 8 STRK and +6 went straight back out as the fee, netting 2. That is what "may still need private balance for +the fee quote" means in practice: *something* must fund that outbound 6 STRK. + +For a first-time claimant with nothing inside the pool, this is the open risk — and it is the +reason the deposit-bundling workaround matters, because a deposit in the same transaction is +demonstrably enough to cover it. + +Live values: `get_fee_amount()` = **6 STRK**, `fee_collector` = +`0xd79041634625e5288296fbc648088788710ba44903a3a49468a66567749e77`. + +## 3. A dapp can drive registration itself — no wallet menus + +The standalone registrations are plain `INVOKE` v3 transactions with **one call, straight to the +pool**, on selector `0x246333a7…` — which is `apply_actions`. + +That is a public entrypoint. Registration is not locked inside a wallet's private UI. + +Combined with MAINNET-DAY-0's note that the viewing key derivation only needs `signMessage` +("your wallet needs no STRK20 support for this"), the conclusion is: + +**Our claim page can register the user itself, with any Starknet wallet.** The fallback is two +clicks on our page, not "go into your wallet's settings and come back" — which was the difference +that mattered most for the product. + +--- + +## What this changes + +- **The two-step fallback is now acceptable**, not embarrassing. Same page, two prompts. +- **Bundling a small self-deposit into the claim is an evidence-backed workaround** for both the + registration and the fee problem at once. That precise shape is running on mainnet today. +- **The remaining unknown narrows sharply**: does a *pure receive* — no deposit — fold in + registration and fund the fee? Nothing on-chain answers that, because nobody has done it. Only + our own probe or support can. + +## Confidence + +Items 1–3 are measured from mainnet, not inferred from documentation. The sample is small +(12 registrations, 6 transactions inspected in full) and drawn from one 50 000-block window, so +treat the *counts* as indicative. The *existence* proofs — registration bundles, the pool +reimburses the fee, `apply_actions` is directly callable — need only one example each, and each +has several. + +--- + +# 4. A zero-balance claimant cannot pay the fee in a pure receive — from the pool's own rules + +The remaining question split into a wallet half (does Ready emit phase 0 for a pure receive?) and +a protocol half (can a zero-balance account pay the fee at all?). The protocol half is decidable +without a browser, and the answer is no. + +**The balance invariant.** Every pool transaction tracks a per-token running balance across its +client actions: + +| Action | Effect | +|---|---| +| `deposit` | add | +| `use_note` (spend an existing note) | add | +| `withdraw` | subtract | +| `create_enc_note` / `create_open_note` | subtract | + +`subtract_balance` uses `checked_sub` and panics `NEGATIVE_INTERMEDIATE_BALANCE`; at the end +`assert_valid` requires **every token to net exactly zero**, or `FINAL_BALANCE_MUST_BE_ZERO`. + +The mainnet transaction traced above fits exactly: `+8` deposit, `−2` note, `−6` fee = 0. + +**`invoke_external` has no balance effect.** It only emits `ServerAction::Invoke`; the escrow's +returned `OpenNoteDeposit` is applied server-side, outside this accounting. + +So Xenia's claim, for a first-time user, is: + +``` +create_open_note (zero-value) −0 +invoke_external no effect +withdraw 6 STRK (the fee) −6 + ──── +inflows 0 → checked_sub(0, 6) panics +``` + +**The fee withdrawal has nothing to balance against.** This is not Ready being incomplete — the +pool would reject the transaction whatever wallet built it. + +## What that leaves + +Two ways a claim can work, and only two: + +1. **Sponsorship.** If the relayer absorbs the fee without reclaiming it, there is no `withdraw` + action at all and the invariant holds trivially (0 = 0). Support's phrasing — "unless the flow + is sponsored" — implies this exists. Whether it can be turned on for our claim is now **the + single question worth asking.** +2. **Bundle a deposit** of at least the fee into the claim. That supplies the inflow, and it is the + exact shape already running on mainnet (`ViewingKeySet + Deposit + … + Withdrawal`). Cost: the + claimant needs ~6 STRK of *public* STRK, which weakens "arrives with nothing" but is far more + tractable than needing private balance. + +Option 2 is the fallback we can build without anyone's permission. Option 1 is strictly better if +available. + +**Confidence:** derived from the pool's source and consistent with every mainnet transaction +observed. What is *not* established is whether sponsorship can suppress the fee withdrawal for our +flow — that is a policy question, not a code one. + +--- + +# 5. Sponsorship is not observed anywhere — measured + +Option 1 (a sponsored claim, emitting no fee withdrawal) was tested by classifying real pool +transactions. For each: does it emit `Withdrawal`, and who sent the 6 STRK to the fee collector? + +- `SELF_PAID` — no withdrawal, fee paid publicly by the submitter +- `RELAYER_REIMBURSED` — withdrawal present, fee fronted by someone other than the user +- `SPONSORED` — no withdrawal, and the fee paid by a third party + +Sample of 18 transactions carrying `Deposit` or `OpenNoteCreated`, drawn from the last 40 000 +blocks: + +``` +SELF_PAID: 0 RELAYER_REIMBURSED: 18 SPONSORED: 0 NO_FEE_SEEN: 0 +``` + +Adding the three standalone registrations examined earlier, the pattern is consistent: + +| Transaction | Submitter | Fee paid by | Withdrawal? | +|---|---|---|---| +| Registration alone | the user | the user, publicly | no | +| Anything with notes or deposits | a relayer | relayer, reimbursed from the pool | **yes** | + +**Every note-bearing transaction goes through a relayer and reimburses out of the pool.** A claim is +note-bearing. So the reimbursement withdrawal is not optional in practice, and it is what needs an +inflow to balance against. + +**Conclusion: do not plan around sponsorship.** Nothing on mainnet is using it. If it exists it is a +private arrangement, which makes it a question for the organisers rather than something we can +switch on. Option 2 — bundling a deposit that covers the fee — is the only path demonstrated to +work, and all 18 of those transactions are examples of it. + +A third path exists but does not generalise: paying the fee publicly from the user's own account, +as the standalone registrations do. It requires a standing STRK allowance to the pool and, more +importantly, was never observed on a note-bearing transaction — plausibly because those go through +the proving and relayer infrastructure. Worth one probe, but not worth designing around. diff --git a/contracts/Scarb.lock b/contracts/Scarb.lock new file mode 100644 index 0000000..dde8fd6 --- /dev/null +++ b/contracts/Scarb.lock @@ -0,0 +1,163 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "openzeppelin" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:ded6f53e0b50a3583f72c556d9ca508e627d175c6f01a2f5124f2e9a052ac985" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_governance", + "openzeppelin_interfaces", + "openzeppelin_introspection", + "openzeppelin_merkle_tree", + "openzeppelin_presets", + "openzeppelin_security", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_access" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:2c7fab22d2601fca4f456c81272637f2563a423652d1671383bbe3d007803977" +dependencies = [ + "openzeppelin_interfaces", + "openzeppelin_introspection", +] + +[[package]] +name = "openzeppelin_account" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:b46f41be21fff6692d32949664d2f0dd1919c741346a676d6e0f1fe2ea576999" +dependencies = [ + "openzeppelin_interfaces", + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_finance" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:f77f4f5262666d8033f16b3a7df839ed2abb699a09ad33099c7bb6bf2807fec1" +dependencies = [ + "openzeppelin_access", + "openzeppelin_interfaces", + "openzeppelin_token", +] + +[[package]] +name = "openzeppelin_governance" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:b76c87f72b1481a62e3c6904a9a3da5cfc4de8b5a86a4742de30f1e2984c7e2b" +dependencies = [ + "openzeppelin_access", + "openzeppelin_interfaces", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_interfaces" +version = "2.1.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:f69fdb36eb894a0e0732385e723c5ff56d8cb4c1d49b29446c77eefac00b02a5" + +[[package]] +name = "openzeppelin_introspection" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:ee491981a69736cde220f8b7dd290b6d8620c85ee6b83c81f665f5bef78b62b1" +dependencies = [ + "openzeppelin_interfaces", +] + +[[package]] +name = "openzeppelin_merkle_tree" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:3417680a1f672bd6cfb54962481f6b96d72f6510be2658d17c8356709b5c950a" + +[[package]] +name = "openzeppelin_presets" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:1facc433476df1c36ecb33e3a10b6a5fb219c670c437b43696788843fbc0bc25" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_interfaces", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_security" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:470f2debae50e03f1435874bc697a0825c257edcad4d1a0cc134e40360e6aba8" +dependencies = [ + "openzeppelin_interfaces", +] + +[[package]] +name = "openzeppelin_token" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:5ce19d297251d9f11acc38a3e3e2faeb2e73b003c8de2f02c7c5d06d9161a5fc" +dependencies = [ + "openzeppelin_access", + "openzeppelin_interfaces", + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_upgrades" +version = "3.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:1b148d5da1ae90a056b455e8865260423c5491a82777377abfdc68fd8e7d0675" + +[[package]] +name = "openzeppelin_utils" +version = "2.1.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:4d5504fef1c5a6d9fee6a3ae392004a4a24b4b3ccb790c5e5217da96beb73e08" +dependencies = [ + "openzeppelin_interfaces", +] + +[[package]] +name = "snforge_scarb_plugin" +version = "0.63.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:05f937b499ddaf534271de8766d8e2a17dd5675776c5a5db3dd769cdfe7be6d2" + +[[package]] +name = "snforge_std" +version = "0.63.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:8b0efc66fc4275dde83b79bfe5b0ff6b486d16dd559e0e3c9077afb21760c939" +dependencies = [ + "snforge_scarb_plugin", +] + +[[package]] +name = "xenia" +version = "0.1.0" +dependencies = [ + "openzeppelin", + "snforge_std", +] diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml new file mode 100644 index 0000000..807d26d --- /dev/null +++ b/contracts/Scarb.toml @@ -0,0 +1,26 @@ +[package] +name = "xenia" +version = "0.1.0" +edition = "2024_07" +license-file = "../LICENSE" + +[dependencies] +starknet = "2.20.0" +openzeppelin = "3.0.0" + +[dev-dependencies] +snforge_std = "0.63.0" +assert_macros = "2.20.0" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std"] + +[features] +test_contracts = [] + +[scripts] +test = "snforge test --features test_contracts" + +[[target.starknet-contract]] +sierra = true +casm = true diff --git a/contracts/scripts/.env.example b/contracts/scripts/.env.example new file mode 100644 index 0000000..015a88f --- /dev/null +++ b/contracts/scripts/.env.example @@ -0,0 +1,26 @@ +# Copy to `.env` and fill in. `.env` is gitignored — never commit it, never paste it into chat. +# +# cp .env.example .env +# npm run deploy:dry # class hash only, submits nothing +# npm run deploy # declare + deploy + +# ── Sepolia (rehearse here first) ──────────────────────────────────────────────────────────── +STARKNET_RPC_URL=https://api.cartridge.gg/x/starknet/sepolia +POOL_ADDRESS=0x0254a6b2997ef52e9f830ce1f543f6b29768295e8d17e2267d672c552cfe0d91 +# STRK — the token the pool charges its fee in. Same address on both networks. +FEE_TOKEN=0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d + +# ── Mainnet (only after Sepolia works) ─────────────────────────────────────────────────────── +# STARKNET_RPC_URL=https://rpc.starknet.lava.build +# POOL_ADDRESS=0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a +# CONFIRM_MAINNET=yes + +# ── The deploying account ──────────────────────────────────────────────────────────────────── +# A Starknet account (Ready or Braavos), already deployed on-chain and funded with test STRK. +# Use a burner. This key can spend everything the account holds. +DEPLOYER_ADDRESS= +DEPLOYER_PRIVATE_KEY= + +# Ready/Argent accounts from v0.4 need a 5-felt signature. Use `argent` for Ready or Braavos-X, +# `standard` (default) for OpenZeppelin-style accounts that accept a bare [r, s]. +ACCOUNT_TYPE=argent diff --git a/contracts/scripts/declare-and-deploy.mjs b/contracts/scripts/declare-and-deploy.mjs new file mode 100644 index 0000000..9e46ccf --- /dev/null +++ b/contracts/scripts/declare-and-deploy.mjs @@ -0,0 +1,163 @@ +/** + * Declare and deploy `XeniaEscrow`. + * + * Exists because `sncast` has no Windows binary and will not build from source on the contract + * machine. starknet.js does the same job with the Node that is already installed, and reads the + * Sierra and CASM that `scarb build` already produces. + * + * Usage: + * + * npm install + * node declare-and-deploy.mjs --dry-run # compute the class hash, submit nothing + * node declare-and-deploy.mjs # declare + deploy + * + * Credentials live in `.env` (gitignored, never committed). Copy `.env.example` and fill it in; + * the npm scripts load it with Node's --env-file. Variables: + * + * STARKNET_RPC_URL RPC endpoint. Mainnet: https://rpc.starknet.lava.build + * DEPLOYER_ADDRESS Account that pays for the declare and deploy + * DEPLOYER_PRIVATE_KEY Its private key + * POOL_ADDRESS Constructor argument: the STRK20 privacy pool + * CONFIRM_MAINNET=yes Required only when the RPC reports SN_MAIN + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Account, CallData, RpcProvider, Signer, constants, ec, hash } from 'starknet'; + +/** + * Signer for Argent / Ready accounts from v0.4 onwards. + * + * Those accounts validate against `Array` rather than a bare `[r, s]`, because + * they support several signer types (Starknet key, secp256k1, WebAuthn…). starknet.js's default + * `Signer` emits two felts, and the account rejects it with `argent/invalid-signature-length`. + * + * For a single Starknet-key owner the expected encoding is five felts: + * + * [ 1, 0, pubkey, r, s ] + * │ │ + * │ └── enum variant 0 = Starknet + * └───── array length: one SignerSignature + * + * Every signing path in `Signer` funnels through `signRaw`, so overriding it alone covers + * declare, deploy and invoke. + */ +class ArgentV4Signer extends Signer { + async signRaw(msgHash) { + const { r, s } = ec.starkCurve.sign(msgHash, this.pk); + return ['0x1', '0x0', ec.starkCurve.getStarkKey(this.pk), `0x${r.toString(16)}`, `0x${s.toString(16)}`]; + } +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const TARGET = resolve(HERE, '..', 'target', 'dev'); +const SIERRA = resolve(TARGET, 'xenia_XeniaEscrow.contract_class.json'); +const CASM = resolve(TARGET, 'xenia_XeniaEscrow.compiled_contract_class.json'); + +/** + * The verified mainnet pool (MAINNET-DAY-0). The address published in the STRK20 docs is Sepolia. + * + * This matters more here than anywhere else: `privacy_contract` is set in the constructor and has + * no setter, and `privacy_invoke` asserts the caller matches it on every path. Deploying to + * mainnet against the Sepolia address produces a contract where every single call reverts + * CALLER_NOT_PRIVACY, recoverable only by redeploying and redoing every transaction. + */ +const MAINNET_POOL = '0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a'; + +const dryRun = process.argv.includes('--dry-run'); + +const die = (message) => { + console.error(`\n ✗ ${message}\n`); + process.exit(1); +}; + +const required = (name) => process.env[name] ?? die(`${name} is not set.`); + +const readJson = (path, what) => { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + die(`Could not read the ${what} at ${path}.\n Run \`scarb build\` in contracts/ first.\n (${error.message})`); + } +}; + +const normalise = (address) => `0x${BigInt(address).toString(16).padStart(64, '0')}`; + +const main = async () => { + const sierra = readJson(SIERRA, 'Sierra class'); + const casm = readJson(CASM, 'CASM class'); + + // Computable without a network, so --dry-run can report it before anything is submitted. + const classHash = hash.computeContractClassHash(sierra); + console.log(`\n class hash ${classHash}`); + + const poolAddress = required('POOL_ADDRESS'); + const feeToken = required('FEE_TOKEN'); + console.log(` pool ${normalise(poolAddress)}`); + console.log(` fee token ${normalise(feeToken)}`); + + if (dryRun) { + console.log('\n Dry run — nothing submitted.\n'); + return; + } + + const provider = new RpcProvider({ nodeUrl: required('STARKNET_RPC_URL') }); + const chainId = await provider.getChainId(); + const isMainnet = chainId === constants.StarknetChainId.SN_MAIN; + console.log(` chain ${chainId}${isMainnet ? ' (MAINNET)' : ''}`); + + if (isMainnet) { + if (normalise(poolAddress) !== normalise(MAINNET_POOL)) { + die( + `POOL_ADDRESS does not match the verified mainnet pool.\n` + + ` given ${normalise(poolAddress)}\n` + + ` expected ${normalise(MAINNET_POOL)}\n` + + ` The constructor has no setter. Deploying this would brick every call.`, + ); + } + if (process.env.CONFIRM_MAINNET !== 'yes') { + die('Refusing to deploy to mainnet without CONFIRM_MAINNET=yes.'); + } + } + + // starknet.js v10 takes a single options object. The older positional form + // `new Account(provider, address, key)` silently binds `provider` to `options` and then dies on + // `address.toLowerCase()`, which reads like a bug in your own code. + const key = required('DEPLOYER_PRIVATE_KEY'); + const accountType = (process.env.ACCOUNT_TYPE ?? 'standard').toLowerCase(); + console.log(` account type ${accountType}`); + + const account = new Account({ + provider, + address: required('DEPLOYER_ADDRESS'), + signer: accountType === 'argent' ? new ArgentV4Signer(key) : key, + }); + + console.log('\n Declaring…'); + // `declareIfNot` is a no-op when the class is already on-chain, which makes a re-run after a + // failed deploy cheap rather than an error. + const declared = await account.declareIfNot({ contract: sierra, casm }); + if (declared.transaction_hash) { + console.log(` declare tx ${declared.transaction_hash}`); + await provider.waitForTransaction(declared.transaction_hash); + } else { + console.log(' already declared'); + } + + console.log('\n Deploying…'); + const deployed = await account.deployContract({ + classHash: declared.class_hash ?? classHash, + constructorCalldata: CallData.compile([poolAddress, feeToken]), + }); + console.log(` deploy tx ${deployed.transaction_hash}`); + await provider.waitForTransaction(deployed.transaction_hash); + + console.log(`\n ✓ XeniaEscrow deployed\n`); + console.log(` address ${deployed.contract_address}`); + console.log(` class hash ${declared.class_hash ?? classHash}\n`); + console.log(' Next: put the address in strk20.json "contracts" and in'); + console.log(' NEXT_PUBLIC_XENIA_ESCROW for the client.\n'); +}; + +main().catch((error) => die(error.stack ?? String(error))); diff --git a/contracts/scripts/js-reference.mjs b/contracts/scripts/js-reference.mjs new file mode 100644 index 0000000..1f104cc --- /dev/null +++ b/contracts/scripts/js-reference.mjs @@ -0,0 +1,43 @@ +/** + * Produce the reference values that `tests/test_js_interop.cairo` asserts. + * + * The point is to catch a disagreement between the two halves of Xenia — the client derives and + * signs in JavaScript, the contract recomputes in Cairo, and a mismatch in a domain tag or a + * hash-padding convention reverts every claim. Cheap to catch here, expensive to catch on mainnet. + * + * These calls mirror `src/lib/xenia/crypto.ts` exactly. If that file changes, run this again and + * paste the output into the Cairo test. + * + * node js-reference.mjs + */ + +import { ec, hash, shortString } from 'starknet'; + +/** Fixed so the values are reproducible run to run. Not a key anyone should fund. */ +const SK = '0x03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f'; +const CLAIMANT = '0x048f5f116ba486a079969bdc934846998f0099c11d58874cdb5983a7411addf4'; + +const hex = (v) => + typeof v === 'string' ? (v.startsWith('0x') ? v : `0x${v}`) : `0x${v.toString(16)}`; + +const COMMITMENT_TAG = shortString.encodeShortString('XENIA_COMMITMENT_V1'); +const CLAIM_TAG = shortString.encodeShortString('XENIA_CLAIM_V1'); +const REFUND_TAG = shortString.encodeShortString('XENIA_REFUND_V1'); + +const pk = hex(ec.starkCurve.getStarkKey(SK)); +const commitment = hex(hash.computePoseidonHashOnElements([COMMITMENT_TAG, pk])); +const claimMsg = hex(hash.computePoseidonHashOnElements([CLAIM_TAG, commitment, CLAIMANT])); +const refundMsg = hex(hash.computePoseidonHashOnElements([REFUND_TAG, commitment, CLAIMANT])); +const sig = ec.starkCurve.sign(claimMsg, SK); + +console.log(` +Paste into contracts/tests/test_js_interop.cairo: + +const SK_PUBKEY: felt252 = ${pk}; +const CLAIMANT_ADDR: felt252 = ${CLAIMANT}; +const JS_COMMITMENT: felt252 = ${commitment}; +const JS_CLAIM_MSG: felt252 = ${claimMsg}; +const JS_REFUND_MSG: felt252 = ${refundMsg}; +const JS_SIG_R: felt252 = ${hex(sig.r)}; +const JS_SIG_S: felt252 = ${hex(sig.s)}; +`); diff --git a/contracts/scripts/new-deployer.mjs b/contracts/scripts/new-deployer.mjs new file mode 100644 index 0000000..6bf2261 --- /dev/null +++ b/contracts/scripts/new-deployer.mjs @@ -0,0 +1,107 @@ +/** + * Create and deploy a dedicated deployer account. + * + * Why not just use a wallet account: Ready/Argent accounts from v0.4 with Argent Shield enabled + * carry a guardian, and validation then needs both the owner's and the guardian's signature. The + * guardian key is held by Argent, so such an account cannot be driven from a script at all. A + * plain OpenZeppelin account has one owner and accepts a bare [r, s]. + * + * It is also simply better practice: a deploy key should be purpose-built and disposable, not the + * key to a wallet you also use for anything else. + * + * node --env-file=.env new-deployer.mjs --generate # make a keypair, write .env, print address + * node --env-file=.env new-deployer.mjs --deploy # deploy it, once funded + * + * The generated private key is written to `.env` and never printed. Only the address is shown, + * because the address is what you need in order to fund it. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Account, RpcProvider, ec, hash, stark } from 'starknet'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** Which env file to write into. Mainnet and Sepolia keep separate ones so a deploy cannot + * pick up the wrong network's account by accident. */ +const ENV = resolve(HERE, process.env.ENV_FILE ?? '.env'); + +/** OpenZeppelin account, already declared on both Sepolia and mainnet. */ +const OZ_CLASS_HASH = '0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f'; + +const STRK = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; + +const die = (m) => { + console.error(`\n ✗ ${m}\n`); + process.exit(1); +}; + +/** Address is deterministic from the key, so it can be funded before the account exists. */ +const addressFor = (publicKey) => + hash.calculateContractAddressFromHash(publicKey, OZ_CLASS_HASH, [publicKey], 0); + +const setEnv = (key, value) => { + let text = readFileSync(ENV, 'utf8'); + text = text.match(new RegExp(`^${key}=.*$`, 'm')) + ? text.replace(new RegExp(`^${key}=.*$`, 'm'), `${key}=${value}`) + : `${text.replace(/\n*$/, '\n')}${key}=${value}\n`; + writeFileSync(ENV, text); +}; + +const generate = () => { + const privateKey = stark.randomAddress(); + const publicKey = ec.starkCurve.getStarkKey(privateKey); + const address = addressFor(publicKey); + + setEnv('DEPLOYER_PRIVATE_KEY', privateKey); + setEnv('DEPLOYER_ADDRESS', address); + setEnv('ACCOUNT_TYPE', 'standard'); + + console.log('\n A new deployer account has been written to .env.'); + console.log(' The private key is in that file only — it is deliberately not printed here.\n'); + console.log(` address ${address}`); + console.log(` class hash ${OZ_CLASS_HASH}`); + console.log('\n Send it a few Sepolia STRK from Ready, then run:'); + console.log(' node --env-file=.env new-deployer.mjs --deploy\n'); +}; + +const deploy = async () => { + const privateKey = process.env.DEPLOYER_PRIVATE_KEY ?? die('DEPLOYER_PRIVATE_KEY is not set.'); + const publicKey = ec.starkCurve.getStarkKey(privateKey); + const address = addressFor(publicKey); + const provider = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL ?? die('STARKNET_RPC_URL is not set.') }); + + console.log(`\n address ${address}`); + + try { + await provider.getClassHashAt(address); + console.log(' already deployed — nothing to do\n'); + return; + } catch { + /* expected: not deployed yet */ + } + + const balance = await provider.callContract({ + contractAddress: STRK, + entrypoint: 'balanceOf', + calldata: [address], + }); + const raw = BigInt(balance[0]) + (BigInt(balance[1] ?? '0x0') << 128n); + console.log(` STRK ${raw / 10n ** 18n}.${(raw % 10n ** 18n).toString().padStart(18, '0').slice(0, 4)}`); + if (raw === 0n) die('Not funded yet. Send some STRK to the address above first.'); + + const account = new Account({ provider, address, signer: privateKey }); + console.log('\n Deploying account…'); + const { transaction_hash, contract_address } = await account.deployAccount({ + classHash: OZ_CLASS_HASH, + constructorCalldata: [publicKey], + addressSalt: publicKey, + }); + console.log(` tx ${transaction_hash}`); + await provider.waitForTransaction(transaction_hash); + console.log(`\n ✓ deployer account live at ${contract_address}\n`); +}; + +if (process.argv.includes('--generate')) generate(); +else if (process.argv.includes('--deploy')) await deploy(); +else die('Pass --generate or --deploy.'); diff --git a/contracts/scripts/package-lock.json b/contracts/scripts/package-lock.json new file mode 100644 index 0000000..4597405 --- /dev/null +++ b/contracts/scripts/package-lock.json @@ -0,0 +1,593 @@ +{ + "name": "xenia-contracts-scripts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xenia-contracts-scripts", + "version": "0.1.0", + "dependencies": { + "starknet": "^10.4.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz", + "integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/starknet": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@scure/starknet/-/starknet-1.1.0.tgz", + "integrity": "sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.7.0", + "@noble/hashes": "~1.6.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-5.0.0.tgz", + "integrity": "sha512-isDNGDlp16W24HE4IuweYXLDRZN0JbsDnazAieeKXE87Mn+jqhsjgTsMxcwWTjX7v906Bjz39FiDjGUddnr36g==", + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "^0.7.10", + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0", + "ox": "^0.4.4" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6": { + "name": "@starknet-io/get-starknet-wallet-standard", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-6.0.4.tgz", + "integrity": "sha512-HhJlC7lSqaiFZPcn+nY4j3hNyzprt7KC7UZY6TFi8w1TNRYccGV9dwY2NtJ+bfLl5mv5ZSxt9OigRn9t6WzNLg==", + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "0.10.4-beta.2", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "ox": "^0.4.4" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@starknet-io/types-js": { + "version": "0.10.4-beta.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4-beta.2.tgz", + "integrity": "sha512-DHfzg/d6s1NYeQpbBpLwXxYBVgCMhMIW2RjbSS2ukwz8XbPZQCLwj8ArAH4Tx4dmkJpn4J6YVRhZGFgjO26TVQ==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0101": { + "name": "@starknet-io/types-js", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.2.tgz", + "integrity": "sha512-AtUFPYdmo9DqVus++aBSoY9W13/2PZmillPr8/mXZjc+V0iYJ/QTmkTsbw+es2mnLeLhYWSymW9ivQzyyyKdog==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0103": { + "name": "@starknet-io/types-js", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.3.tgz", + "integrity": "sha512-WtTGjqgyjqYSaSks/CQrpERGiLlwhr1TTD4llsr8IKEZHb78OJEmEhzrb/LxJV1SIz+MEsB1pioG62BOmFKYLA==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0104": { + "name": "@starknet-io/types-js", + "version": "0.10.4-beta.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4-beta.2.tgz", + "integrity": "sha512-DHfzg/d6s1NYeQpbBpLwXxYBVgCMhMIW2RjbSS2ukwz8XbPZQCLwj8ArAH4Tx4dmkJpn4J6YVRhZGFgjO26TVQ==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-09": { + "name": "@starknet-io/types-js", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.9.2.tgz", + "integrity": "sha512-vWOc0FVSn+RmabozIEWcEny1I73nDGTvOrLYJsR1x7LGA3AZmqt4i/aW69o/3i2NN5CVP8Ok6G1ayRQJKye3Wg==", + "license": "MIT" + }, + "node_modules/@starknet-io/types-js": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.7.10.tgz", + "integrity": "sha512-1VtCqX4AHWJlRRSYGSn+4X1mqolI1Tdq62IwzoU2vUuEE72S1OlEeGhpvd6XsdqXcfHmVzYfj8k1XtKBQqwo9w==", + "license": "MIT" + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", + "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz", + "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/abi-wan-kanabi": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/abi-wan-kanabi/-/abi-wan-kanabi-2.2.4.tgz", + "integrity": "sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg==", + "license": "ISC", + "dependencies": { + "ansicolors": "^0.3.2", + "cardinal": "^2.1.1", + "fs-extra": "^10.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "generate": "dist/generate.js" + } + }, + "node_modules/abitype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "license": "MIT" + }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lossless-json": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.1.tgz", + "integrity": "sha512-SqD/Bg3ZfltBJ2Z14hJ/BihnvtV553WO4g9/ePtlp4lrnl9jF3AdIJt53A/Wkg/0Li+LMfxaBqgx1MiFZdQlpQ==", + "license": "MIT" + }, + "node_modules/ox": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.4.4.tgz", + "integrity": "sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/starknet": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/starknet/-/starknet-10.7.1.tgz", + "integrity": "sha512-w3sOO0ZTAvn9YXTh9J0C7MPodvfZqbNSPBRKZtuTXEsYyLb6avdH0oMQ3WfDWP44D9De4E8boz/kLAolRVa9JA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.7.0", + "@noble/hashes": "~1.6.0", + "@scure/base": "~1.2.1", + "@scure/starknet": "1.1.0", + "@starknet-io/get-starknet-wallet-standard": "^5.0.0", + "@starknet-io/get-starknet-wallet-standard-v6": "npm:@starknet-io/get-starknet-wallet-standard@6.0.4", + "@starknet-io/starknet-types-0101": "npm:@starknet-io/types-js@0.10.2", + "@starknet-io/starknet-types-0103": "npm:@starknet-io/types-js@0.10.3", + "@starknet-io/starknet-types-0104": "npm:@starknet-io/types-js@0.10.4-beta.2", + "@starknet-io/starknet-types-09": "npm:@starknet-io/types-js@~0.9.2", + "abi-wan-kanabi": "2.2.4", + "lossless-json": "^4.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/contracts/scripts/package.json b/contracts/scripts/package.json new file mode 100644 index 0000000..4928f4e --- /dev/null +++ b/contracts/scripts/package.json @@ -0,0 +1,17 @@ +{ + "name": "xenia-contracts-scripts", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Declare and deploy XeniaEscrow. Kept separate from the client so contract tooling does not depend on the Next.js build.", + "scripts": { + "preflight": "node --env-file=.env preflight.mjs", + "deploy": "node --env-file=.env declare-and-deploy.mjs", + "deploy:dry": "node --env-file=.env declare-and-deploy.mjs --dry-run", + "mainnet:preflight": "node --env-file=.env.mainnet preflight.mjs", + "mainnet:deploy": "node --env-file=.env.mainnet declare-and-deploy.mjs" + }, + "dependencies": { + "starknet": "^10.4.0" + } +} diff --git a/contracts/scripts/preflight.mjs b/contracts/scripts/preflight.mjs new file mode 100644 index 0000000..271e872 --- /dev/null +++ b/contracts/scripts/preflight.mjs @@ -0,0 +1,95 @@ +/** + * Pre-flight checks on the deploying account, before anything is submitted. + * + * Catches the three things that waste a deploy attempt: + * 1. The account is not deployed on-chain. Starknet accounts are contracts, and a funded but + * undeployed account cannot send transactions. This is the usual first-time snag. + * 2. The private key does not control the address. Cheaper to learn here than from a rejected + * transaction. + * 3. The account cannot pay for the declare, which is the expensive half. + * + * Reads the same `.env` as the deploy script: npm run preflight + */ + +import { RpcProvider, ec } from 'starknet'; + +const STRK = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; +const ETH = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7'; + +const hex = (v) => `0x${BigInt(v).toString(16)}`; +const eq = (a, b) => BigInt(a) === BigInt(b); + +const fmt = (low, high, decimals = 18) => { + const raw = BigInt(low) + (BigInt(high) << 128n); + const whole = raw / 10n ** BigInt(decimals); + const frac = (raw % 10n ** BigInt(decimals)).toString().padStart(decimals, '0').slice(0, 4); + return `${whole}.${frac}`; +}; + +const address = process.env.DEPLOYER_ADDRESS; +const privateKey = process.env.DEPLOYER_PRIVATE_KEY; +const rpc = process.env.STARKNET_RPC_URL; +if (!address || !privateKey || !rpc) { + console.error('\n ✗ DEPLOYER_ADDRESS, DEPLOYER_PRIVATE_KEY and STARKNET_RPC_URL must all be set.\n'); + process.exit(1); +} + +const provider = new RpcProvider({ nodeUrl: rpc }); +let ok = true; + +console.log(`\n chain ${await provider.getChainId()}`); +console.log(` account ${hex(address)}`); + +// 1 — deployed? +let classHash = null; +try { + classHash = await provider.getClassHashAt(hex(address)); + console.log(` class ${classHash}`); +} catch { + // Not fatal yet — the balance below decides whether this needs a faucet or just a deploy. + console.log(' class NOT DEPLOYED'); + ok = false; +} + +// 2 — does the key control it? +const expected = ec.starkCurve.getStarkKey(privateKey); +let onchain = null; +for (const entrypoint of classHash ? ['get_owner', 'getPublicKey', 'get_public_key'] : []) { + try { + const r = await provider.callContract({ contractAddress: hex(address), entrypoint, calldata: [] }); + if (r?.length) { + onchain = r[0]; + break; + } + } catch { + /* wallets expose different names; try the next */ + } +} +if (onchain === null) { + console.log(` signer not readable yet — key derives ${expected}`); +} else if (eq(onchain, expected)) { + console.log(` signer ${onchain} ✓ matches the private key`); +} else { + console.log(`\n ✗ KEY MISMATCH`); + console.log(` on-chain signer ${hex(onchain)}`); + console.log(` from private key ${expected}`); + console.log(' This key does not control this address.\n'); + ok = false; +} + +// 3 — can it pay? +for (const [name, token] of [['STRK', STRK], ['ETH', ETH]]) { + try { + const r = await provider.callContract({ + contractAddress: token, + entrypoint: 'balanceOf', + calldata: [hex(address)], + }); + console.log(` ${name.padEnd(9)} ${fmt(r[0], r[1] ?? '0x0')}`); + } catch { + console.log(` ${name.padEnd(9)} (could not read)`); + } +} + +console.log(ok ? '\n ✓ ready to deploy\n' : '\n ✗ fix the above first\n'); +process.exit(ok ? 0 : 1); diff --git a/contracts/scripts/probe.html b/contracts/scripts/probe.html new file mode 100644 index 0000000..067e818 --- /dev/null +++ b/contracts/scripts/probe.html @@ -0,0 +1,339 @@ + + + + + +Xenia wallet probe + + + +
+

Xenia wallet probe

+

+ Answers three things without spending anything: does this wallet speak STRK20, has this account + ever registered, and what does the pool charge. All read-only — nothing is submitted. +

+ +
+

1 · Wallet

+
+ + + +
+
+
+
+ +
+

2 · Does it speak STRK20?

+

+ wallet_strk20Balances is read-only and safe to call against any wallet. A wallet + that answers "not implemented" has told us to show a different path. +

+
Connect first.
+
+ +
+

3 · Has this account registered?

+

+ Read straight from the pool. A zero public key means never registered — which is exactly the + account we need for the claim test. Keep it that way. +

+
Connect first.
+
+ +
+

4 · Verdict

+
Waiting…
+
+
+ + + + diff --git a/contracts/src/lib.cairo b/contracts/src/lib.cairo new file mode 100644 index 0000000..b4cb246 --- /dev/null +++ b/contracts/src/lib.cairo @@ -0,0 +1,4 @@ +#[cfg(feature: "test_contracts")] +pub mod mocks; +pub mod open_note; +pub mod xenia_escrow; diff --git a/contracts/src/mocks.cairo b/contracts/src/mocks.cairo new file mode 100644 index 0000000..5a2689e --- /dev/null +++ b/contracts/src/mocks.cairo @@ -0,0 +1,142 @@ +//! Test-only contracts. Gated behind the `test_contracts` feature so they never reach a +//! deployment artifact — run the suite with `snforge test --features test_contracts`. + +/// The smallest ERC-20 that satisfies `IERC20Dispatcher`. `XeniaEscrow` only ever calls `approve`, +/// but the full trait has to be present for the dispatcher's ABI to line up. +#[starknet::contract] +pub mod MockERC20 { + use openzeppelin::interfaces::token::erc20::IERC20; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + total_supply: u256, + balances: Map, + allowances: Map<(ContractAddress, ContractAddress), u256>, + } + + #[constructor] + fn constructor(ref self: ContractState, recipient: ContractAddress, supply: u256) { + self.total_supply.write(supply); + self.balances.write(recipient, supply); + } + + #[abi(embed_v0)] + impl MockERC20Impl of IERC20 { + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.balances.read(account) + } + + fn allowance( + self: @ContractState, owner: ContractAddress, spender: ContractAddress, + ) -> u256 { + self.allowances.read((owner, spender)) + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + let sender = get_caller_address(); + self.balances.write(sender, self.balances.read(sender) - amount); + self.balances.write(recipient, self.balances.read(recipient) + amount); + true + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256, + ) -> bool { + let spender = get_caller_address(); + let allowed = self.allowances.read((sender, spender)); + self.allowances.write((sender, spender), allowed - amount); + self.balances.write(sender, self.balances.read(sender) - amount); + self.balances.write(recipient, self.balances.read(recipient) + amount); + true + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + self.allowances.write((get_caller_address(), spender), amount); + true + } + } +} + +/// A stand-in for the privacy pool, exercising the parts of the handshake our own tests otherwise +/// fake. +/// +/// The suite in `test_xenia_escrow.cairo` cheats the caller address and calls through a typed +/// dispatcher, which proves the escrow's logic but not that it can be *driven the way the pool +/// drives it*. The real path is a raw `call_contract_syscall` carrying a flat calldata array, a +/// return value the pool deserialises as `Span`, and a `transfer_from` that +/// depends on the escrow having approved first. Each of those is a place the client can be correct +/// and the integration still fail. +#[starknet::interface] +pub trait IMockPrivacyPool { + /// `withdraw_amount` mirrors the pool's phase-6 Withdraw; pass zero for a claim, which sends + /// nothing and only invokes. + fn withdraw_and_invoke( + ref self: T, + helper: starknet::ContractAddress, + token: starknet::ContractAddress, + withdraw_amount: u256, + calldata: Span, + ) -> Span; +} + +#[starknet::contract] +pub mod MockPrivacyPool { + use core::num::traits::Zero; + use openzeppelin::interfaces::token::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::syscalls::call_contract_syscall; + use starknet::{ContractAddress, get_contract_address}; + use crate::open_note::OpenNoteDeposit; + use super::IMockPrivacyPool; + + /// The selector the real pool uses — `privacy::utils::INVOKE_SELECTOR`. + const INVOKE_SELECTOR: felt252 = selector!("privacy_invoke"); + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl MockPrivacyPoolImpl of IMockPrivacyPool { + fn withdraw_and_invoke( + ref self: ContractState, + helper: ContractAddress, + token: ContractAddress, + withdraw_amount: u256, + calldata: Span, + ) -> Span { + // Phase 6 — the pool moves the input tokens to the helper before invoking it. + if withdraw_amount.is_non_zero() { + IERC20Dispatcher { contract_address: token }.transfer(helper, withdraw_amount); + } + + // Phase 7 — raw syscall with flat calldata, exactly as the pool does it. + let mut returned = call_contract_syscall(helper, INVOKE_SELECTOR, calldata).unwrap(); + let deposits: Span = Serde::deserialize(ref returned).unwrap(); + + // Applying the deposits: pull what the helper approved. Fails if it approved nothing. + let mut remaining = deposits; + loop { + match remaining.pop_front() { + Option::Some(d) => { + IERC20Dispatcher { contract_address: *d.token } + .transfer_from(helper, get_contract_address(), (*d.amount).into()); + }, + Option::None => { break; }, + } + } + + deposits + } + } +} diff --git a/contracts/src/open_note.cairo b/contracts/src/open_note.cairo new file mode 100644 index 0000000..f528965 --- /dev/null +++ b/contracts/src/open_note.cairo @@ -0,0 +1,34 @@ +//! `OpenNoteDeposit`, mirrored from the privacy pool. +//! +//! This is a verbatim mirror of `privacy::objects::OpenNoteDeposit` in +//! [`starkware-libs/starknet-privacy`](https://github.com/starkware-libs/starknet-privacy/blob/main/packages/privacy/src/objects.cairo) +//! (Apache-2.0, StarkWare) — same fields, same order, same derives. +//! +//! ## Why mirrored rather than imported +//! +//! `OpenNoteDeposit` is the only item Xenia needs from that package, and depending on it as a git +//! dependency pulls in its whole workspace — `starkware_utils`, `ekubo`, and the vesu and shadow +//! anonymizer packages — none of which Xenia uses. That tree also breaks the build: scarb +//! re-checks-out `starkware-starknet-utils` on a second invocation in the same job and fails with +//! "Directory not empty", which took CI down. Since the contract machine cannot run the tests +//! locally, a deterministic CI build is worth more than the import. +//! +//! **This is safe because Serde is structural.** The pool deserialises our return value as +//! `Span` — three felts per entry, in this order. A structurally identical +//! struct encodes identically on the wire; the pool never sees a type name. +//! +//! If the upstream struct ever gains a field or reorders one, this must change with it. Check it +//! against the link above before a mainnet deploy. + +use starknet::ContractAddress; + +/// Input for depositing to an open note (returned by an invoked contract). +#[derive(Serde, Copy, Drop, PartialEq, Debug)] +pub struct OpenNoteDeposit { + /// The identifier of the open note to deposit to. + pub note_id: felt252, + /// The ERC20 token contract to deposit. + pub token: ContractAddress, + /// The amount of tokens to deposit. + pub amount: u128, +} diff --git a/contracts/src/xenia_escrow.cairo b/contracts/src/xenia_escrow.cairo new file mode 100644 index 0000000..ef927c8 --- /dev/null +++ b/contracts/src/xenia_escrow.cairo @@ -0,0 +1,362 @@ +//! # XeniaEscrow +//! +//! A stateful `privacy_invoke` anonymizer for the STRK20 pool that lets a sender pay someone who +//! has never registered a viewing key. +//! +//! Derived from the reference escrow helper published on STRK20 by Example +//! () — unofficial, and not audited by StarkWare. +//! Xenia adds, over that reference: +//! +//! * **Expiry and refund**, so a lost link does not strand tokens forever. +//! * **Link keys instead of bare secrets** (see [`compute_commitment`]). The reference escrow +//! authorises a claim with a raw preimage, which is visible in public calldata and replayable by +//! anyone who sees the claim before it is included. Xenia binds the authorisation to the +//! claimant's address with a STARK-curve signature instead. +//! * **Events on every state-changing path**, of which the reference emits none. + +use starknet::ContractAddress; +use crate::open_note::OpenNoteDeposit; + +/// Entry stored per commitment. +/// +/// A zero `token` means "not found" — the same sentinel the reference escrow uses. +#[derive(Serde, Copy, Drop, PartialEq, Debug, starknet::Store)] +pub struct ClaimEntry { + pub token: ContractAddress, + pub amount: u128, + /// Absolute block timestamp. Claims are valid strictly before it, refunds at or after it. + pub expiry: u64, + /// Recorded at deposit and echoed in `ClaimRefunded`. See the note on refund authorisation in + /// [`IXeniaEscrow::privacy_invoke`] — this field is metadata, not an access check. + pub refund_to: ContractAddress, + /// Flips exactly once, by either a claim or a refund. The two are mutually exclusive. + pub claimed: bool, +} + +/// Operation to perform on the escrow. +#[derive(Serde, Copy, Drop, PartialEq, Debug)] +pub enum XeniaOperation { + Deposit, + Claim, + Refund, +} + +#[starknet::interface] +pub trait IXeniaEscrow { + /// Returns the entry for a commitment. All fields are zero if it does not exist. + fn get_claim(self: @T, commitment: felt252) -> ClaimEntry; + + /// The privacy pool this escrow accepts calls from. + fn privacy_contract(self: @T) -> ContractAddress; + + /// Called by the privacy pool via the `INVOKE_SELECTOR` during `InvokeExternal`. + /// + /// The pool deserialises calldata straight into these parameters, so the order is frozen + /// (PRD §4.1) and must match the client byte for byte. + /// + /// **Deposit** — records a claim backed by tokens the pool has already withdrawn to this + /// contract. Returns an empty span; there is nothing to credit yet. + /// * `commitment` — `poseidon(XENIA_COMMITMENT_TAG_V1, pk)`, computed off-chain. + /// * `token`, `amount`, `expiry`, `refund_to` — the entry to store. + /// * `claimant` — **reused on Deposit** as the address to pre-fund, or zero for none. + /// * `note_id` — **reused on Deposit** as how much fee token to pre-fund it with. + /// * `sig_r`, `sig_s` — ignored. + /// + /// Pre-funding exists so a claimant can arrive with an empty wallet. The pool charges a fee in + /// STRK per transaction, and its balance invariant requires that fee to be matched by an inflow + /// inside the same transaction — which someone holding nothing cannot provide. Sending them + /// the fee ahead of time, out of the escrow rather than out of the sender's own address, + /// supplies it without putting a sender-to-recipient edge on chain. + /// + /// The two parameters are reused rather than appended because the pool deserialises calldata + /// positionally: appending would change the calldata length and break every existing caller. + /// Both were already passed as zero on Deposit, so zero keeps the old behaviour exactly. + /// + /// **Claim** — proves possession of the link key and credits the claimant's open note. + /// * `commitment` — carries the link **public key** `pk`, not the stored key. The contract + /// recomputes `poseidon(TAG, pk)` itself and looks that up, so a passed-in commitment is + /// never trusted as authorisation. + /// * `claimant` — the address the signature authorises. + /// * `sig_r`, `sig_s` — signature by `sk` over `poseidon(XENIA_CLAIM_TAG_V1, key, claimant)`. + /// * `note_id` — the open note to credit, supplied by the wallet as `${openNoteIds[0]}`. + /// * `token`, `amount`, `expiry`, `refund_to` — ignored; the stored entry wins. + /// + /// **Refund** — the same shape as Claim, but valid only at or after `expiry`, and signed + /// under a distinct domain tag so a claim signature can never be replayed as a refund. + fn privacy_invoke( + ref self: T, + operation: XeniaOperation, + commitment: felt252, + token: ContractAddress, + amount: u128, + expiry: u64, + refund_to: ContractAddress, + claimant: ContractAddress, + sig_r: felt252, + sig_s: felt252, + note_id: felt252, + ) -> Span; +} + +/// Domain-separation tags. Distinct tags keep Xenia commitments from colliding with the reference +/// escrow's, and keep a claim signature from being replayed as a refund. +pub const XENIA_COMMITMENT_TAG_V1: felt252 = 'XENIA_COMMITMENT_V1'; +pub const XENIA_CLAIM_TAG_V1: felt252 = 'XENIA_CLAIM_V1'; +pub const XENIA_REFUND_TAG_V1: felt252 = 'XENIA_REFUND_V1'; + +pub mod errors { + pub const ZERO_COMMITMENT: felt252 = 'ZERO_COMMITMENT'; + pub const ZERO_TOKEN: felt252 = 'ZERO_TOKEN'; + pub const ZERO_AMOUNT: felt252 = 'ZERO_AMOUNT'; + pub const EXPIRY_IN_PAST: felt252 = 'EXPIRY_IN_PAST'; + pub const COMMITMENT_EXISTS: felt252 = 'COMMITMENT_EXISTS'; + pub const COMMITMENT_NOT_FOUND: felt252 = 'COMMITMENT_NOT_FOUND'; + pub const ALREADY_CLAIMED: felt252 = 'ALREADY_CLAIMED'; + pub const CLAIM_EXPIRED: felt252 = 'CLAIM_EXPIRED'; + pub const NOT_YET_EXPIRED: felt252 = 'NOT_YET_EXPIRED'; + pub const NOT_REFUND_OWNER: felt252 = 'NOT_REFUND_OWNER'; + pub const BAD_SIGNATURE: felt252 = 'BAD_SIGNATURE'; + pub const CALLER_NOT_PRIVACY: felt252 = 'CALLER_NOT_PRIVACY'; + pub const PREFUND_TOO_LARGE: felt252 = 'PREFUND_TOO_LARGE'; +} + +/// The storage key for a link key pair: `poseidon(TAG, pk)`. +/// +/// The link in `https://host/c#` carries the **private** key. `pk` is derived client-side and +/// only ever appears in calldata alongside a signature that binds it to one claimant address. +pub fn compute_commitment(link_pubkey: felt252) -> felt252 { + core::poseidon::poseidon_hash_span([XENIA_COMMITMENT_TAG_V1, link_pubkey].span()) +} + +/// The message a claimant must present a signature over. +pub fn claim_message(commitment: felt252, claimant: ContractAddress) -> felt252 { + core::poseidon::poseidon_hash_span([XENIA_CLAIM_TAG_V1, commitment, claimant.into()].span()) +} + +/// The message a refunder must present a signature over. A separate tag keeps the two disjoint. +pub fn refund_message(commitment: felt252, refunder: ContractAddress) -> felt252 { + core::poseidon::poseidon_hash_span([XENIA_REFUND_TAG_V1, commitment, refunder.into()].span()) +} + +#[starknet::contract] +pub mod XeniaEscrow { + use core::ecdsa::check_ecdsa_signature; + use core::num::traits::Zero; + use openzeppelin::interfaces::token::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use crate::open_note::OpenNoteDeposit; + use super::{ + ClaimEntry, IXeniaEscrow, XeniaOperation, claim_message, compute_commitment, errors, + refund_message, + }; + + #[storage] + struct Storage { + privacy_contract: ContractAddress, + claims: Map, + /// The token the pool charges its per-transaction fee in — STRK. Held separately from the + /// claim token because a claim denominated in USDC still owes its fee in STRK. + fee_token: ContractAddress, + } + + /// Every state-changing path emits one of these. This is not polish: the sprint validator + /// requires each listed transaction to carry an event from a listed contract, and the + /// reference escrow emits nothing (PRD §3). + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + ClaimCreated: ClaimCreated, + ClaimPrefunded: ClaimPrefunded, + ClaimRedeemed: ClaimRedeemed, + ClaimRefunded: ClaimRefunded, + } + + #[derive(Drop, starknet::Event)] + pub struct ClaimCreated { + #[key] + pub commitment: felt252, + pub token: ContractAddress, + pub amount: u128, + pub expiry: u64, + } + + /// Emitted when a deposit pre-funds an address so the claimant can arrive with an empty wallet. + /// Separate from `ClaimCreated` so that event's shape stays exactly as the client already reads + /// it. + #[derive(Drop, starknet::Event)] + pub struct ClaimPrefunded { + #[key] + pub commitment: felt252, + pub recipient: ContractAddress, + pub amount: u128, + } + + #[derive(Drop, starknet::Event)] + pub struct ClaimRedeemed { + #[key] + pub commitment: felt252, + pub claimant: ContractAddress, + pub amount: u128, + } + + #[derive(Drop, starknet::Event)] + pub struct ClaimRefunded { + #[key] + pub commitment: felt252, + pub refund_to: ContractAddress, + pub amount: u128, + } + + #[constructor] + fn constructor( + ref self: ContractState, privacy_contract: ContractAddress, fee_token: ContractAddress, + ) { + self.privacy_contract.write(privacy_contract); + self.fee_token.write(fee_token); + } + + #[abi(embed_v0)] + pub impl XeniaEscrowImpl of IXeniaEscrow { + fn get_claim(self: @ContractState, commitment: felt252) -> ClaimEntry { + self.claims.read(commitment) + } + + fn privacy_contract(self: @ContractState) -> ContractAddress { + self.privacy_contract.read() + } + + fn privacy_invoke( + ref self: ContractState, + operation: XeniaOperation, + commitment: felt252, + token: ContractAddress, + amount: u128, + expiry: u64, + refund_to: ContractAddress, + claimant: ContractAddress, + sig_r: felt252, + sig_s: felt252, + note_id: felt252, + ) -> Span { + let privacy_addr = self.privacy_contract.read(); + assert(get_caller_address() == privacy_addr, errors::CALLER_NOT_PRIVACY); + + match operation { + XeniaOperation::Deposit => { + assert(commitment.is_non_zero(), errors::ZERO_COMMITMENT); + assert(token.is_non_zero(), errors::ZERO_TOKEN); + assert(amount.is_non_zero(), errors::ZERO_AMOUNT); + assert(expiry > get_block_timestamp(), errors::EXPIRY_IN_PAST); + + let existing = self.claims.read(commitment); + assert(existing.token.is_zero(), errors::COMMITMENT_EXISTS); + + self + .claims + .write( + commitment, + ClaimEntry { token, amount, expiry, refund_to, claimed: false }, + ); + + self.emit(ClaimCreated { commitment, token, amount, expiry }); + + // Optional pre-funding. The sender's client withdraws the fee token to this + // contract alongside the claim token, and we forward it to the address derived + // from the link key, so the claimant can cover the pool fee without ever having + // held anything. + if claimant.is_non_zero() && note_id.is_non_zero() { + let prefund: u128 = note_id.try_into().expect(errors::PREFUND_TOO_LARGE); + IERC20Dispatcher { contract_address: self.fee_token.read() } + .transfer(recipient: claimant, amount: prefund.into()); + self + .emit( + ClaimPrefunded { commitment, recipient: claimant, amount: prefund }, + ); + } + + // The pool already moved the tokens here via its Withdraw action, so there is + // nothing for it to credit. An empty span is valid: "credit nothing". + // + // Bound to a name because a bare `[].span()` straight after an `if` block + // parses as an index expression on that block. + let nothing_to_credit: Span = [].span(); + nothing_to_credit + }, + XeniaOperation::Claim => { + // `commitment` carries the link public key; the stored key is recomputed. + let key = compute_commitment(commitment); + let entry = self.settle(key, commitment, claimant, sig_r, sig_s, false); + + self.emit(ClaimRedeemed { commitment: key, claimant, amount: entry.amount }); + + [OpenNoteDeposit { note_id, token: entry.token, amount: entry.amount }].span() + }, + XeniaOperation::Refund => { + let key = compute_commitment(commitment); + let entry = self.settle(key, commitment, claimant, sig_r, sig_s, true); + + self + .emit( + ClaimRefunded { + commitment: key, refund_to: entry.refund_to, amount: entry.amount, + }, + ); + + [OpenNoteDeposit { note_id, token: entry.token, amount: entry.amount }].span() + }, + } + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + /// The half that claim and refund share: look the entry up, check the window, verify the + /// signature, flip `claimed` exactly once, and approve the pool to pull. + /// + /// `is_refund` selects both the time window and the signature's domain tag. + fn settle( + ref self: ContractState, + key: felt252, + link_pubkey: felt252, + recipient: ContractAddress, + sig_r: felt252, + sig_s: felt252, + is_refund: bool, + ) -> ClaimEntry { + let entry = self.claims.read(key); + assert(entry.token.is_non_zero(), errors::COMMITMENT_NOT_FOUND); + assert(!entry.claimed, errors::ALREADY_CLAIMED); + + let now = get_block_timestamp(); + + if is_refund { + assert(now >= entry.expiry, errors::NOT_YET_EXPIRED); + let message = refund_message(key, recipient); + assert( + check_ecdsa_signature(message, link_pubkey, sig_r, sig_s), + errors::NOT_REFUND_OWNER, + ); + } else { + assert(now < entry.expiry, errors::CLAIM_EXPIRED); + let message = claim_message(key, recipient); + assert( + check_ecdsa_signature(message, link_pubkey, sig_r, sig_s), + errors::BAD_SIGNATURE, + ); + } + + self.claims.write(key, ClaimEntry { claimed: true, ..entry }); + + // Approve, never transfer — the pool pulls the tokens itself when it applies the + // returned deposit. + IERC20Dispatcher { contract_address: entry.token } + .approve(spender: self.privacy_contract.read(), amount: entry.amount.into()); + + entry + } + } +} diff --git a/contracts/tests/lib.cairo b/contracts/tests/lib.cairo new file mode 100644 index 0000000..f115f48 --- /dev/null +++ b/contracts/tests/lib.cairo @@ -0,0 +1,3 @@ +mod test_js_interop; +mod test_pool_handshake; +mod test_xenia_escrow; diff --git a/contracts/tests/test_js_interop.cairo b/contracts/tests/test_js_interop.cairo new file mode 100644 index 0000000..23f3f03 --- /dev/null +++ b/contracts/tests/test_js_interop.cairo @@ -0,0 +1,70 @@ +//! Cross-language agreement between the client and the contract. +//! +//! The client derives the link key, hashes the commitment and signs the claim in JavaScript; the +//! contract recomputes all of it in Cairo. If the two disagree by so much as a domain tag or a +//! hash-padding convention, every claim reverts `COMMITMENT_NOT_FOUND` or `BAD_SIGNATURE` — and +//! it does so on mainnet, with real money, having looked fine in every isolated test. +//! +//! So the values below are **produced by JavaScript**, using exactly the calls +//! `src/lib/xenia/crypto.ts` makes: +//! +//! ```js +//! const pk = ec.starkCurve.getStarkKey(SK); +//! const commitment = hash.computePoseidonHashOnElements([COMMITMENT_TAG, pk]); +//! const claimMsg = hash.computePoseidonHashOnElements([CLAIM_TAG, commitment, claimant]); +//! const sig = ec.starkCurve.sign(claimMsg, SK); +//! ``` +//! +//! Regenerate with `contracts/scripts/js-reference.mjs` if the tags or the derivation ever change. +//! A failure here means the two halves of Xenia have drifted apart. + +use xenia::xenia_escrow::{claim_message, compute_commitment, refund_message}; + +/// Fixed private key, so the values are reproducible: +/// `0x03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f` +const SK_PUBKEY: felt252 = 0x6a1061177c2ac48f00771e7b40a46c8f12be1ca9d5d4a9fefb76742e8aee8c4; +const CLAIMANT_ADDR: felt252 = 0x048f5f116ba486a079969bdc934846998f0099c11d58874cdb5983a7411addf4; + +const JS_COMMITMENT: felt252 = 0x121e2e8a3e39c976541c4aec0c9ba566dcb1af2d1bc5d473441bbc4851dba2b; +const JS_CLAIM_MSG: felt252 = 0x7b3325e71990a02c3861aec2e7166a7f39e3b703816da209694a2c9bc42f7c1; +const JS_REFUND_MSG: felt252 = 0x147106a7b6069b7460d6ef016b37e47eb11d7942a12d3ac8d0d1b731f84cf8b; +const JS_SIG_R: felt252 = 0x5e73504908cb975519c083370fe51f3398f4b8707e82b984abf3c0f94a9be99; +const JS_SIG_S: felt252 = 0x2209e7281d77328c533813f81dd5f204e673327ae3ac87c2e13cc1f6ab2a8c1; + +/// Poseidon over `[tag, pk]` must land on the same felt in both languages, or a claim looks up a +/// commitment that was never stored. +#[test] +fn commitment_matches_the_client() { + let cairo = compute_commitment(SK_PUBKEY); + assert!(cairo == JS_COMMITMENT, "commitment differs from the client's"); +} + +#[test] +fn claim_message_matches_the_client() { + let claimant = CLAIMANT_ADDR.try_into().unwrap(); + let cairo = claim_message(JS_COMMITMENT, claimant); + assert!(cairo == JS_CLAIM_MSG, "claim message differs from the client's"); +} + +#[test] +fn refund_message_matches_the_client() { + let refunder = CLAIMANT_ADDR.try_into().unwrap(); + let cairo = refund_message(JS_COMMITMENT, refunder); + assert!(cairo == JS_REFUND_MSG, "refund message differs from the client's"); +} + +/// The one that matters most: a signature produced by `ec.starkCurve.sign` in the browser must +/// satisfy `check_ecdsa_signature` on chain — same curve, same encoding, same argument order. +#[test] +fn a_signature_made_in_javascript_verifies_in_cairo() { + let ok = core::ecdsa::check_ecdsa_signature(JS_CLAIM_MSG, SK_PUBKEY, JS_SIG_R, JS_SIG_S); + assert!(ok, "a client-produced signature was rejected by the contract's check"); +} + +/// And it must not verify against a different message, or the binding that stops a claim being +/// redirected in the mempool is doing nothing. +#[test] +fn the_signature_does_not_verify_for_another_message() { + let ok = core::ecdsa::check_ecdsa_signature(JS_REFUND_MSG, SK_PUBKEY, JS_SIG_R, JS_SIG_S); + assert!(!ok, "a claim signature verified against the refund message"); +} diff --git a/contracts/tests/test_pool_handshake.cairo b/contracts/tests/test_pool_handshake.cairo new file mode 100644 index 0000000..13ce28f --- /dev/null +++ b/contracts/tests/test_pool_handshake.cairo @@ -0,0 +1,201 @@ +//! The full lifecycle driven the way the pool drives it. +//! +//! `test_xenia_escrow.cairo` proves the escrow's logic, but it cheats the caller address and calls +//! through a typed dispatcher. That leaves three things unexercised, and all three sit exactly +//! where a client integration goes wrong: +//! +//! 1. **Flat calldata.** The pool forwards a `Span` and Starknet deserialises it +//! positionally into `privacy_invoke`'s ten parameters. The arrays below are byte-for-byte what +//! `contracts/CLIENT-FIXES.md` tells the client to send — if the shape is wrong, these fail. +//! 2. **The returned span.** The pool deserialises our return value as `Span`. An +//! empty span on deposit and a one-entry span on claim both have to survive that round trip. +//! 3. **Approve, then pull.** The escrow approves and the pool calls `transfer_from`. If the +//! approval is missing or short, the pull reverts — and nothing in the dispatcher tests would +//! have noticed. + +use openzeppelin::interfaces::token::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::signature::KeyPairTrait; +use snforge_std::signature::stark_curve::{StarkCurveKeyPairImpl, StarkCurveSignerImpl}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_block_timestamp_global, +}; +use starknet::ContractAddress; +use xenia::mocks::{IMockPrivacyPoolDispatcher, IMockPrivacyPoolDispatcherTrait}; +use xenia::xenia_escrow::{ + IXeniaEscrowDispatcher, IXeniaEscrowDispatcherTrait, claim_message, compute_commitment, + refund_message, +}; + +const SUPPLY: u256 = 1_000_000_000; +const AMOUNT: u128 = 250_000; +const START_TS: u64 = 1_000; +const EXPIRY: u64 = 2_000; +const NOTE_ID: felt252 = 'NOTE_ID'; + +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} + +/// Token, pool and escrow, wired as they are on chain: the escrow trusts the pool, and the pool +/// holds the tokens it will withdraw. +fn setup() -> (IMockPrivacyPoolDispatcher, IXeniaEscrowDispatcher, ContractAddress) { + start_cheat_block_timestamp_global(START_TS); + + let pool_class = declare("MockPrivacyPool").unwrap().contract_class(); + let (pool, _) = pool_class.deploy(@array![]).unwrap(); + + let token_class = declare("MockERC20").unwrap().contract_class(); + let mut args = array![]; + pool.serialize(ref args); // the pool starts holding the supply + SUPPLY.serialize(ref args); + let (token, _) = token_class.deploy(@args).unwrap(); + + let escrow_class = declare("XeniaEscrow").unwrap().contract_class(); + let (escrow, _) = escrow_class.deploy(@array![pool.into(), token.into()]).unwrap(); + + ( + IMockPrivacyPoolDispatcher { contract_address: pool }, + IXeniaEscrowDispatcher { contract_address: escrow }, + token, + ) +} + +/// Exactly the array in CLIENT-FIXES.md: ten felts, unused positions zero. +fn deposit_calldata( + commitment: felt252, token: ContractAddress, refund_to: ContractAddress, +) -> Array { + array![ + 0, // Deposit + commitment, token.into(), AMOUNT.into(), EXPIRY.into(), refund_to.into(), 0, + 0, 0, 0, + ] +} + +fn settle_calldata( + operation: felt252, link_pubkey: felt252, recipient: ContractAddress, r: felt252, s: felt252, +) -> Array { + array![operation, link_pubkey, 0, 0, 0, 0, recipient.into(), r, s, NOTE_ID] +} + +#[test] +fn a_deposit_parks_the_funds_and_returns_an_empty_span() { + let (pool, escrow, token) = setup(); + let erc20 = IERC20Dispatcher { contract_address: token }; + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + + let deposits = pool + .withdraw_and_invoke( + escrow.contract_address, + token, + AMOUNT.into(), + deposit_calldata(commitment, token, addr('SENDER')).span(), + ); + + // An empty span has to survive the round trip, or the pool rejects the call outright. + assert!(deposits.len() == 0, "deposit should credit nothing"); + assert!(erc20.balance_of(escrow.contract_address) == AMOUNT.into(), "funds not parked"); + + let entry = escrow.get_claim(commitment); + assert!(entry.token == token && entry.amount == AMOUNT, "entry not stored"); +} + +#[test] +fn a_claim_credits_the_pool_and_the_pool_can_pull() { + let (pool, escrow, token) = setup(); + let erc20 = IERC20Dispatcher { contract_address: token }; + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + let claimant = addr('CLAIMANT'); + + pool + .withdraw_and_invoke( + escrow.contract_address, + token, + AMOUNT.into(), + deposit_calldata(commitment, token, addr('SENDER')).span(), + ); + let pool_before = erc20.balance_of(pool.contract_address); + + let (r, s) = link_key.sign(claim_message(commitment, claimant)).unwrap(); + let deposits = pool + .withdraw_and_invoke( + escrow.contract_address, + token, + 0, // a claim sends nothing in + settle_calldata(1, link_key.public_key, claimant, r, s).span(), + ); + + assert!(deposits.len() == 1, "claim should credit exactly one note"); + let d = *deposits.at(0); + assert!(d.note_id == NOTE_ID, "wrong note id"); + assert!(d.token == token, "wrong token"); + assert!(d.amount == AMOUNT, "wrong amount"); + + // The pull only works because the escrow approved first. + assert!( + erc20.balance_of(pool.contract_address) == pool_before + AMOUNT.into(), + "pool could not pull the approved tokens", + ); + assert!(erc20.balance_of(escrow.contract_address) == 0, "escrow should be empty"); + assert!(escrow.get_claim(commitment).claimed, "claim not marked"); +} + +#[test] +fn a_refund_after_expiry_returns_the_funds_through_the_pool() { + let (pool, escrow, token) = setup(); + let erc20 = IERC20Dispatcher { contract_address: token }; + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + let sender = addr('SENDER'); + + pool + .withdraw_and_invoke( + escrow.contract_address, + token, + AMOUNT.into(), + deposit_calldata(commitment, token, sender).span(), + ); + + start_cheat_block_timestamp_global(EXPIRY); + let (r, s) = link_key.sign(refund_message(commitment, sender)).unwrap(); + let deposits = pool + .withdraw_and_invoke( + escrow.contract_address, + token, + 0, + settle_calldata(2, link_key.public_key, sender, r, s).span(), + ); + + assert!(deposits.len() == 1, "refund should credit exactly one note"); + assert!(*deposits.at(0).amount == AMOUNT, "wrong refund amount"); + assert!(erc20.balance_of(escrow.contract_address) == 0, "escrow should be empty"); +} + +/// The whole point of the design: a claim is authorised for one address and cannot be redirected by +/// whoever sees it first. +#[test] +#[should_panic] +fn a_claim_redirected_to_another_address_reverts_through_the_pool() { + let (pool, escrow, token) = setup(); + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + + pool + .withdraw_and_invoke( + escrow.contract_address, + token, + AMOUNT.into(), + deposit_calldata(commitment, token, addr('SENDER')).span(), + ); + + // Signed for CLAIMANT, submitted naming ATTACKER. + let (r, s) = link_key.sign(claim_message(commitment, addr('CLAIMANT'))).unwrap(); + pool + .withdraw_and_invoke( + escrow.contract_address, + token, + 0, + settle_calldata(1, link_key.public_key, addr('ATTACKER'), r, s).span(), + ); +} diff --git a/contracts/tests/test_xenia_escrow.cairo b/contracts/tests/test_xenia_escrow.cairo new file mode 100644 index 0000000..faf2de7 --- /dev/null +++ b/contracts/tests/test_xenia_escrow.cairo @@ -0,0 +1,459 @@ +//! The suite PRD §4.7 requires, plus deposit validation. +//! +//! Note on the refund cases: PRD §4.7 words them as "refund by anyone other than `refund_to`". +//! That check is not implementable — `get_caller_address()` is always the pool. Refund is +//! authorised by a signature under `XENIA_REFUND_TAG_V1` instead, so the test asserts the +//! equivalent property: a refund not signed by the link key reverts `NOT_REFUND_OWNER`. See +//! `contracts/INTERFACE.md`. + +use openzeppelin::interfaces::token::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::signature::KeyPairTrait; +use snforge_std::signature::stark_curve::{StarkCurveKeyPairImpl, StarkCurveSignerImpl}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, spy_events, + start_cheat_block_timestamp_global, start_cheat_caller_address, stop_cheat_caller_address, +}; +use starknet::ContractAddress; +use xenia::xenia_escrow::XeniaEscrow::{ClaimCreated, ClaimPrefunded, ClaimRedeemed, ClaimRefunded}; +use xenia::xenia_escrow::{ + IXeniaEscrowDispatcher, IXeniaEscrowDispatcherTrait, XeniaOperation, claim_message, + compute_commitment, refund_message, +}; + +const POOL: felt252 = 'POOL'; +const SENDER: felt252 = 'SENDER'; +const CLAIMANT: felt252 = 'CLAIMANT'; +const ATTACKER: felt252 = 'ATTACKER'; + +const AMOUNT: u128 = 1_000_000; +/// Fee-token balance handed to the escrow so it has something to pre-fund with. +const ESCROW_FUNDING: u256 = 500_000; +const PREFUND: u128 = 2_000; +const START_TS: u64 = 1_000; +const EXPIRY: u64 = 2_000; + +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} + +/// Deploys the mock token and the escrow, and puts the clock at `START_TS`. +fn setup() -> (IXeniaEscrowDispatcher, ContractAddress) { + start_cheat_block_timestamp_global(START_TS); + + let token_class = declare("MockERC20").unwrap().contract_class(); + let mut token_args = array![]; + addr(SENDER).serialize(ref token_args); + let supply: u256 = 1_000_000_000; + supply.serialize(ref token_args); + let (token, _) = token_class.deploy(@token_args).unwrap(); + + let escrow_class = declare("XeniaEscrow").unwrap().contract_class(); + // The mock doubles as the fee token; a claim in one token still owes its fee in another, so the + // contract keeps them separate. + let (escrow, _) = escrow_class.deploy(@array![POOL, token.into()]).unwrap(); + + // Give the escrow a balance so the pre-funding path has something to send. + start_cheat_caller_address(token, addr(SENDER)); + IERC20Dispatcher { contract_address: token }.transfer(escrow, ESCROW_FUNDING); + stop_cheat_caller_address(token); + + (IXeniaEscrowDispatcher { contract_address: escrow }, token) +} + +/// A deposit as the pool would make it. Returns the link key pair. +fn deposit( + escrow: IXeniaEscrowDispatcher, token: ContractAddress, +) -> snforge_std::signature::KeyPair { + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Deposit, + commitment, + token, + AMOUNT, + EXPIRY, + addr(SENDER), + addr(0), + 0, + 0, + 0, + ); + stop_cheat_caller_address(escrow.contract_address); + + link_key +} + +/// Calls Claim (or Refund) with a signature by `signer` binding `recipient`. +fn settle( + escrow: IXeniaEscrowDispatcher, + link_key: snforge_std::signature::KeyPair, + signer: snforge_std::signature::KeyPair, + recipient: ContractAddress, + is_refund: bool, +) { + let commitment = compute_commitment(link_key.public_key); + let message = if is_refund { + refund_message(commitment, recipient) + } else { + claim_message(commitment, recipient) + }; + let (r, s) = signer.sign(message).unwrap(); + let operation = if is_refund { + XeniaOperation::Refund + } else { + XeniaOperation::Claim + }; + + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + operation, link_key.public_key, addr(0), 0, 0, addr(0), recipient, r, s, 'NOTE_ID', + ); + stop_cheat_caller_address(escrow.contract_address); +} + +// ── Access control +// ────────────────────────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected: 'CALLER_NOT_PRIVACY')] +fn caller_that_is_not_the_pool_reverts() { + let (escrow, token) = setup(); + start_cheat_caller_address(escrow.contract_address, addr(ATTACKER)); + escrow + .privacy_invoke( + XeniaOperation::Deposit, + 'COMMITMENT', + token, + AMOUNT, + EXPIRY, + addr(SENDER), + addr(0), + 0, + 0, + 0, + ); +} + +// ── Deposit +// ───────────────────────────────────────────────────────────────────────────────── + +#[test] +fn deposit_stores_the_entry_and_emits() { + let (escrow, token) = setup(); + let mut spy = spy_events(); + + let link_key = deposit(escrow, token); + let commitment = compute_commitment(link_key.public_key); + + let entry = escrow.get_claim(commitment); + assert!(entry.token == token, "token"); + assert!(entry.amount == AMOUNT, "amount"); + assert!(entry.expiry == EXPIRY, "expiry"); + assert!(entry.refund_to == addr(SENDER), "refund_to"); + assert!(!entry.claimed, "claimed"); + + spy + .assert_emitted( + @array![ + ( + escrow.contract_address, + xenia::xenia_escrow::XeniaEscrow::Event::ClaimCreated( + ClaimCreated { commitment, token, amount: AMOUNT, expiry: EXPIRY }, + ), + ), + ], + ); +} + +#[test] +#[should_panic(expected: 'COMMITMENT_EXISTS')] +fn deposit_rejects_a_duplicate_commitment() { + let (escrow, token) = setup(); + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Deposit, + commitment, + token, + AMOUNT, + EXPIRY, + addr(SENDER), + addr(0), + 0, + 0, + 0, + ); + escrow + .privacy_invoke( + XeniaOperation::Deposit, + commitment, + token, + AMOUNT, + EXPIRY, + addr(SENDER), + addr(0), + 0, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: 'EXPIRY_IN_PAST')] +fn deposit_rejects_an_expiry_in_the_past() { + let (escrow, token) = setup(); + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Deposit, + 'COMMITMENT', + token, + AMOUNT, + START_TS, + addr(SENDER), + addr(0), + 0, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: 'ZERO_AMOUNT')] +fn deposit_rejects_a_zero_amount() { + let (escrow, token) = setup(); + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Deposit, 'COMMITMENT', token, 0, EXPIRY, addr(SENDER), addr(0), 0, 0, 0, + ); +} + +// ── Claim +// ─────────────────────────────────────────────────────────────────────────────────── + +#[test] +fn claim_succeeds_once_and_credits_the_right_amount() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + let commitment = compute_commitment(link_key.public_key); + let mut spy = spy_events(); + + settle(escrow, link_key, link_key, addr(CLAIMANT), false); + + assert!(escrow.get_claim(commitment).claimed, "claimed flag did not flip"); + + spy + .assert_emitted( + @array![ + ( + escrow.contract_address, + xenia::xenia_escrow::XeniaEscrow::Event::ClaimRedeemed( + ClaimRedeemed { commitment, claimant: addr(CLAIMANT), amount: AMOUNT }, + ), + ), + ], + ); +} + +#[test] +#[should_panic(expected: 'ALREADY_CLAIMED')] +fn second_claim_reverts() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + settle(escrow, link_key, link_key, addr(CLAIMANT), false); + settle(escrow, link_key, link_key, addr(CLAIMANT), false); +} + +#[test] +#[should_panic(expected: 'BAD_SIGNATURE')] +fn claim_with_a_signature_over_a_different_address_reverts() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + + // Sign for CLAIMANT, then submit naming ATTACKER — the front-running case from PRD §4.5. + let commitment = compute_commitment(link_key.public_key); + let (r, s) = link_key.sign(claim_message(commitment, addr(CLAIMANT))).unwrap(); + + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Claim, + link_key.public_key, + addr(0), + 0, + 0, + addr(0), + addr(ATTACKER), + r, + s, + 'NOTE_ID', + ); +} + +#[test] +#[should_panic(expected: 'CLAIM_EXPIRED')] +fn claim_after_expiry_reverts() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + start_cheat_block_timestamp_global(EXPIRY); + settle(escrow, link_key, link_key, addr(CLAIMANT), false); +} + +#[test] +#[should_panic(expected: 'COMMITMENT_NOT_FOUND')] +fn claim_against_an_unknown_link_key_reverts() { + let (escrow, token) = setup(); + deposit(escrow, token); + let stranger = KeyPairTrait::::generate(); + settle(escrow, stranger, stranger, addr(CLAIMANT), false); +} + +// ── Refund +// ────────────────────────────────────────────────────────────────────────────────── + +#[test] +fn refund_after_expiry_succeeds_and_emits() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + let commitment = compute_commitment(link_key.public_key); + start_cheat_block_timestamp_global(EXPIRY); + let mut spy = spy_events(); + + settle(escrow, link_key, link_key, addr(SENDER), true); + + assert!(escrow.get_claim(commitment).claimed, "claimed flag did not flip"); + + spy + .assert_emitted( + @array![ + ( + escrow.contract_address, + xenia::xenia_escrow::XeniaEscrow::Event::ClaimRefunded( + ClaimRefunded { commitment, refund_to: addr(SENDER), amount: AMOUNT }, + ), + ), + ], + ); +} + +#[test] +#[should_panic(expected: 'NOT_YET_EXPIRED')] +fn refund_before_expiry_reverts() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + settle(escrow, link_key, link_key, addr(SENDER), true); +} + +#[test] +#[should_panic(expected: 'NOT_REFUND_OWNER')] +fn refund_not_signed_by_the_link_key_reverts() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + let attacker_key = KeyPairTrait::::generate(); + start_cheat_block_timestamp_global(EXPIRY); + settle(escrow, link_key, attacker_key, addr(ATTACKER), true); +} + +#[test] +#[should_panic(expected: 'ALREADY_CLAIMED')] +fn refund_after_a_claim_reverts() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + settle(escrow, link_key, link_key, addr(CLAIMANT), false); + start_cheat_block_timestamp_global(EXPIRY); + settle(escrow, link_key, link_key, addr(SENDER), true); +} + +#[test] +#[should_panic(expected: 'BAD_SIGNATURE')] +fn a_claim_signature_cannot_be_replayed_as_a_refund() { + let (escrow, token) = setup(); + let link_key = deposit(escrow, token); + let commitment = compute_commitment(link_key.public_key); + + // A signature made under the refund tag, submitted as a Claim. Domain separation rejects it. + let (r, s) = link_key.sign(refund_message(commitment, addr(CLAIMANT))).unwrap(); + + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Claim, + link_key.public_key, + addr(0), + 0, + 0, + addr(0), + addr(CLAIMANT), + r, + s, + 'NOTE_ID', + ); +} + + +// ── Pre-funding +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn deposit_prefunds_the_named_address() { + let (escrow, token) = setup(); + let link_key = KeyPairTrait::::generate(); + let commitment = compute_commitment(link_key.public_key); + let erc20 = IERC20Dispatcher { contract_address: token }; + let before = erc20.balance_of(addr(CLAIMANT)); + let mut spy = spy_events(); + + start_cheat_caller_address(escrow.contract_address, addr(POOL)); + escrow + .privacy_invoke( + XeniaOperation::Deposit, + commitment, + token, + AMOUNT, + EXPIRY, + addr(SENDER), + addr(CLAIMANT), // reused on Deposit: who to pre-fund + 0, + 0, + PREFUND.into() // reused on Deposit: how much + ); + stop_cheat_caller_address(escrow.contract_address); + + assert!(erc20.balance_of(addr(CLAIMANT)) == before + PREFUND.into(), "prefund not received"); + + spy + .assert_emitted( + @array![ + ( + escrow.contract_address, + xenia::xenia_escrow::XeniaEscrow::Event::ClaimPrefunded( + ClaimPrefunded { commitment, recipient: addr(CLAIMANT), amount: PREFUND }, + ), + ), + ], + ); +} + +/// Zero in either reused field means "no pre-funding", which is what every existing caller sends. +#[test] +fn deposit_without_prefunding_is_unchanged() { + let (escrow, token) = setup(); + let erc20 = IERC20Dispatcher { contract_address: token }; + let escrow_before = erc20.balance_of(escrow.contract_address); + + let link_key = deposit(escrow, token); + + assert!( + erc20.balance_of(escrow.contract_address) == escrow_before, + "escrow balance should not move", + ); + assert!(!escrow.get_claim(compute_commitment(link_key.public_key)).claimed, "claimed"); +} diff --git a/strk20.json b/strk20.json index 9db404a..a6382f5 100644 --- a/strk20.json +++ b/strk20.json @@ -1,6 +1,8 @@ { "transactions": [], - "contracts": [], + "contracts": [ + "0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095" + ], "demo_video": "", "demo_url": "https://usexenia.vercel.app" }