From 727464c0d57d3b295ae165a22ad0942be44ee7d6 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Mon, 24 Aug 2026 22:20:23 +0100 Subject: [PATCH 01/26] Add XeniaEscrow, its test suite, and contract CI XeniaEscrow is the anonymizer the pool calls during a claim. It extends the reference escrow from STRK20 by Example with the three things Xenia needs: - Expiry and refund, so a lost link does not strand tokens forever. - Link keys instead of bare secrets. The reference authorises a claim with a raw preimage, which sits in public calldata and is replayable by anyone who sees the transaction before inclusion. Xenia binds the authorisation to the claimant's address with a STARK-curve signature, so a copied claim cannot be redirected. - An event on every state-changing path. The reference emits none, and the sprint validator checks transactions for events. Deposit stores the commitment and returns an empty span, since the pool has already withdrawn the tokens to this contract. Claim and refund share one settle path: look the entry up, check the window, verify the signature, flip `claimed` exactly once, and approve the pool to pull. Refund is authorised by a signature under its own domain tag rather than by matching a caller. `privacy_invoke` is always called by the pool, so `get_caller_address()` can never be the sender, and private transactions are submitted by rotating relayers besides. The deviation is documented in INTERFACE.md. Domain tags match the client's strings so both sides hash identically. Tests cover all nine cases the PRD requires plus deposit validation and a signature-replay case. They run in CI: starknet-foundry publishes no Windows binary and building it from source needs more memory than the contract machine has, so Linux runners are where the suite actually executes. --- .github/workflows/contracts.yml | 49 +++ .gitignore | 4 + contracts/INTERFACE.md | 156 ++++++++++ contracts/Scarb.lock | 181 +++++++++++ contracts/Scarb.toml | 30 ++ contracts/src/lib.cairo | 3 + contracts/src/mocks.cairo | 70 +++++ contracts/src/xenia_escrow.cairo | 313 +++++++++++++++++++ contracts/tests/test_xenia_escrow.cairo | 388 ++++++++++++++++++++++++ 9 files changed, 1194 insertions(+) create mode 100644 .github/workflows/contracts.yml create mode 100644 contracts/INTERFACE.md create mode 100644 contracts/Scarb.lock create mode 100644 contracts/Scarb.toml create mode 100644 contracts/src/lib.cairo create mode 100644 contracts/src/mocks.cairo create mode 100644 contracts/src/xenia_escrow.cairo create mode 100644 contracts/tests/test_xenia_escrow.cairo diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..e719554 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,49 @@ +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: + push: + branches: [main] + 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 + + - uses: software-mansion/setup-scarb@v1 + with: + scarb-version: '2.20.1' + + - 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 + + - name: Build + run: scarb build + + # `test_contracts` gates the mock ERC-20 the suite deploys. Without it the mock is not + # compiled and every test fails at `declare("MockERC20")`. + - name: Test + run: snforge test --features test_contracts diff --git a/.gitignore b/.gitignore index 6138b72..0786a47 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ build/ .next/ next-env.d.ts tsconfig.tsbuildinfo + +# Cairo build output +contracts/target/ +.snfoundry_cache/ diff --git a/contracts/INTERFACE.md b/contracts/INTERFACE.md new file mode 100644 index 0000000..849d5a5 --- /dev/null +++ b/contracts/INTERFACE.md @@ -0,0 +1,156 @@ +# `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. + +--- + +## 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` | claimant address | refunder address | +| 7 | `sig_r` | `0` | signature r | signature r | +| 8 | `sig_s` | `0` | signature s | signature s | +| 9 | `note_id` | `0` | `${openNoteIds[0]}` | `${openNoteIds[0]}` | + +> **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". **Sam's call; flagged rather than +decided silently.** PRD §4.4.6 and the §4.7 refund test should be reworded to match whichever +survives. diff --git a/contracts/Scarb.lock b/contracts/Scarb.lock new file mode 100644 index 0000000..26dcb28 --- /dev/null +++ b/contracts/Scarb.lock @@ -0,0 +1,181 @@ +# 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 = "privacy" +version = "0.1.0" +source = "git+https://github.com/starkware-libs/starknet-privacy?rev=51652200561151499b03f90e3a05f03c91f5b349#51652200561151499b03f90e3a05f03c91f5b349" +dependencies = [ + "openzeppelin", + "starkware_utils", +] + +[[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 = "starkware_utils" +version = "1.0.0" +source = "git+https://github.com/starkware-libs/starkware-starknet-utils?rev=3e2fd53d99e16c87f6cf2ced53b8c842a2d54a18#3e2fd53d99e16c87f6cf2ced53b8c842a2d54a18" +dependencies = [ + "openzeppelin", +] + +[[package]] +name = "xenia" +version = "0.1.0" +dependencies = [ + "openzeppelin", + "privacy", + "snforge_std", +] diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml new file mode 100644 index 0000000..1f2f19c --- /dev/null +++ b/contracts/Scarb.toml @@ -0,0 +1,30 @@ +[package] +name = "xenia" +version = "0.1.0" +edition = "2024_07" +license-file = "../LICENSE" + +[dependencies] +starknet = "2.20.0" +openzeppelin = "3.0.0" +privacy = { git = "https://github.com/starkware-libs/starknet-privacy", rev = "51652200561151499b03f90e3a05f03c91f5b349" } + +[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 + +[[test]] +name = "xenia_unittest" diff --git a/contracts/src/lib.cairo b/contracts/src/lib.cairo new file mode 100644 index 0000000..dd4cb86 --- /dev/null +++ b/contracts/src/lib.cairo @@ -0,0 +1,3 @@ +#[cfg(feature: "test_contracts")] +pub mod mocks; +pub mod xenia_escrow; diff --git a/contracts/src/mocks.cairo b/contracts/src/mocks.cairo new file mode 100644 index 0000000..2b1ae89 --- /dev/null +++ b/contracts/src/mocks.cairo @@ -0,0 +1,70 @@ +//! 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 + } + } +} diff --git a/contracts/src/xenia_escrow.cairo b/contracts/src/xenia_escrow.cairo new file mode 100644 index 0000000..64d8dca --- /dev/null +++ b/contracts/src/xenia_escrow.cairo @@ -0,0 +1,313 @@ +//! # 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 privacy::objects::OpenNoteDeposit; +use starknet::ContractAddress; + +/// 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`, `sig_r`, `sig_s`, `note_id` — ignored. + /// + /// **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'; +} + +/// 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 privacy::objects::OpenNoteDeposit; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use super::{ + ClaimEntry, IXeniaEscrow, XeniaOperation, claim_message, compute_commitment, errors, + refund_message, + }; + + #[storage] + struct Storage { + privacy_contract: ContractAddress, + claims: Map, + } + + /// 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, + ClaimRedeemed: ClaimRedeemed, + ClaimRefunded: ClaimRefunded, + } + + #[derive(Drop, starknet::Event)] + pub struct ClaimCreated { + #[key] + pub commitment: felt252, + pub token: ContractAddress, + pub amount: u128, + pub expiry: u64, + } + + #[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) { + self.privacy_contract.write(privacy_contract); + } + + #[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 }); + + // 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". + [].span() + }, + 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/test_xenia_escrow.cairo b/contracts/tests/test_xenia_escrow.cairo new file mode 100644 index 0000000..49223ca --- /dev/null +++ b/contracts/tests/test_xenia_escrow.cairo @@ -0,0 +1,388 @@ +//! 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 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, 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; +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(); + let (escrow, _) = escrow_class.deploy(@array![POOL]).unwrap(); + + (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', + ); +} From 5ba958b68be860b6056d1c9783ae3522205bc43f Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Mon, 24 Aug 2026 22:22:06 +0100 Subject: [PATCH 02/26] Run contract CI on every branch, not just main The contract machine cannot run snforge locally: starknet-foundry ships no Windows binary, and building it from source exhausts memory. CI is therefore the only place the suite executes, so a push has to produce results on its own rather than waiting for a pull request to be opened. --- .github/workflows/contracts.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index e719554..2c830ae 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -5,8 +5,9 @@ name: contracts # 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: - branches: [main] paths: - 'contracts/**' - '.github/workflows/contracts.yml' From 0713a5b00ecddd7a9049708bc3192524658529da Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Mon, 24 Aug 2026 22:30:04 +0100 Subject: [PATCH 03/26] Add a starknet.js declare-and-deploy script for XeniaEscrow sncast has no Windows binary and does not build from source on the contract machine, so the deploy path needed something that works with the Node already installed. This reads the Sierra and CASM that `scarb build` produces and does the declare and deploy through starknet.js. Two guards, because the constructor argument is immutable and `privacy_invoke` asserts the caller matches it on every path. Deploying against the wrong pool gives a contract where every call reverts CALLER_NOT_PRIVACY, fixable only by redeploying and redoing every transaction: - On mainnet, POOL_ADDRESS must equal the verified mainnet pool. The address published in the STRK20 docs is Sepolia, which is the easy mistake. - Mainnet also requires CONFIRM_MAINNET=yes. `--dry-run` computes the class hash without a network or an account, which is enough to check the artifacts are readable before spending anything. Kept in its own package so contract tooling does not depend on the Next.js build. --- contracts/scripts/declare-and-deploy.mjs | 129 +++++ contracts/scripts/package-lock.json | 593 +++++++++++++++++++++++ contracts/scripts/package.json | 14 + 3 files changed, 736 insertions(+) create mode 100644 contracts/scripts/declare-and-deploy.mjs create mode 100644 contracts/scripts/package-lock.json create mode 100644 contracts/scripts/package.json diff --git a/contracts/scripts/declare-and-deploy.mjs b/contracts/scripts/declare-and-deploy.mjs new file mode 100644 index 0000000..600fe25 --- /dev/null +++ b/contracts/scripts/declare-and-deploy.mjs @@ -0,0 +1,129 @@ +/** + * 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 + * + * Environment (a .env is not read — export these, or prefix the command): + * + * 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, constants, hash } from 'starknet'; + +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'); + console.log(` pool ${normalise(poolAddress)}`); + + 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.'); + } + } + + const account = new Account( + provider, + required('DEPLOYER_ADDRESS'), + required('DEPLOYER_PRIVATE_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]), + }); + 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/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..fd46667 --- /dev/null +++ b/contracts/scripts/package.json @@ -0,0 +1,14 @@ +{ + "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": { + "deploy": "node declare-and-deploy.mjs", + "deploy:dry": "node declare-and-deploy.mjs --dry-run" + }, + "dependencies": { + "starknet": "^10.4.0" + } +} From 2df4cd3045a16a30f7a4fa9bef262ef3fde59886 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Mon, 24 Aug 2026 22:42:44 +0100 Subject: [PATCH 04/26] Mirror OpenNoteDeposit locally instead of depending on the privacy package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed in `scarb build` with: failed to remove directory .../checkouts/starkware-starknet-utils-.../3e2fd53 Directory not empty (os error 39) `scarb fmt --check` resolves dependencies and populates the cache, then `scarb build` re-checks-out the same git repository and cannot replace it. The log shows starkware-starknet-utils being fetched twice inside one invocation, once for each package in the privacy workspace that depends on it. Xenia used exactly one item from that dependency: `OpenNoteDeposit`, a struct of three fields. Importing it pulled in the entire privacy workspace — starkware_utils, ekubo, and the vesu and shadow anonymizer packages — none of which this contract touches. Mirroring the struct removes every git dependency. Serde is structural, so the encoding is unchanged: the pool deserialises three felts per entry in the same order and never sees a type name. The ABI confirms the shape is identical. `open_note.cairo` records the provenance and the upstream link to check before a mainnet deploy. The build no longer fetches any git repository, which also makes it fast and deterministic — worth more than the import, given the tests only ever run in CI. --- contracts/Scarb.lock | 18 ----------------- contracts/Scarb.toml | 1 - contracts/src/lib.cairo | 1 + contracts/src/open_note.cairo | 34 ++++++++++++++++++++++++++++++++ contracts/src/xenia_escrow.cairo | 4 ++-- 5 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 contracts/src/open_note.cairo diff --git a/contracts/Scarb.lock b/contracts/Scarb.lock index 26dcb28..dde8fd6 100644 --- a/contracts/Scarb.lock +++ b/contracts/Scarb.lock @@ -139,15 +139,6 @@ dependencies = [ "openzeppelin_interfaces", ] -[[package]] -name = "privacy" -version = "0.1.0" -source = "git+https://github.com/starkware-libs/starknet-privacy?rev=51652200561151499b03f90e3a05f03c91f5b349#51652200561151499b03f90e3a05f03c91f5b349" -dependencies = [ - "openzeppelin", - "starkware_utils", -] - [[package]] name = "snforge_scarb_plugin" version = "0.63.0" @@ -163,19 +154,10 @@ dependencies = [ "snforge_scarb_plugin", ] -[[package]] -name = "starkware_utils" -version = "1.0.0" -source = "git+https://github.com/starkware-libs/starkware-starknet-utils?rev=3e2fd53d99e16c87f6cf2ced53b8c842a2d54a18#3e2fd53d99e16c87f6cf2ced53b8c842a2d54a18" -dependencies = [ - "openzeppelin", -] - [[package]] name = "xenia" version = "0.1.0" dependencies = [ "openzeppelin", - "privacy", "snforge_std", ] diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml index 1f2f19c..ca9fea4 100644 --- a/contracts/Scarb.toml +++ b/contracts/Scarb.toml @@ -7,7 +7,6 @@ license-file = "../LICENSE" [dependencies] starknet = "2.20.0" openzeppelin = "3.0.0" -privacy = { git = "https://github.com/starkware-libs/starknet-privacy", rev = "51652200561151499b03f90e3a05f03c91f5b349" } [dev-dependencies] snforge_std = "0.63.0" diff --git a/contracts/src/lib.cairo b/contracts/src/lib.cairo index dd4cb86..b4cb246 100644 --- a/contracts/src/lib.cairo +++ b/contracts/src/lib.cairo @@ -1,3 +1,4 @@ #[cfg(feature: "test_contracts")] pub mod mocks; +pub mod open_note; pub mod xenia_escrow; 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 index 64d8dca..56fbe43 100644 --- a/contracts/src/xenia_escrow.cairo +++ b/contracts/src/xenia_escrow.cairo @@ -14,8 +14,8 @@ //! claimant's address with a STARK-curve signature instead. //! * **Events on every state-changing path**, of which the reference emits none. -use privacy::objects::OpenNoteDeposit; use starknet::ContractAddress; +use crate::open_note::OpenNoteDeposit; /// Entry stored per commitment. /// @@ -130,12 +130,12 @@ pub mod XeniaEscrow { use core::ecdsa::check_ecdsa_signature; use core::num::traits::Zero; use openzeppelin::interfaces::token::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; - use privacy::objects::OpenNoteDeposit; 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, From 5447dd556c373c5f64c9f6d6591ecbfe4939cfc0 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Mon, 24 Aug 2026 23:00:26 +0100 Subject: [PATCH 05/26] Fix the test target so snforge actually collects the suite The last CI run went green having verified nothing: Collected 0 test(s) from xenia package Tests: 0 passed, 0 failed, 0 ignored, 0 filtered out Scarb.toml declared `[[test]] name = "xenia_unittest"`, copied from the privacy package, where it configures a unit-test target rooted at src/. Declaring it overrides Scarb's auto-detection of tests/, so the suite in tests/test_xenia_escrow.cairo was never built into a target. snforge collected nothing and exited 0, because finding no tests is not an error to it. Removing the override restores auto-detection. tests/lib.cairo makes the integration crate root explicit rather than inferred. The compiled target now carries all fifteen tests, each expanded by the snforge plugin. CI now fails when snforge collects no tests. A suite that silently stops running is worse than one that fails, and this is the second time a green check has meant less than it appeared to. --- .github/workflows/contracts.yml | 13 ++++++++++++- .gitignore | 3 +++ contracts/Scarb.toml | 3 --- contracts/tests/lib.cairo | 1 + 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 contracts/tests/lib.cairo diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 2c830ae..2505c5a 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -46,5 +46,16 @@ jobs: # `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: snforge test --features test_contracts + 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 0786a47..a7a4145 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ tsconfig.tsbuildinfo # Cairo build output contracts/target/ .snfoundry_cache/ + +# snforge log written by CI +contracts/snforge.log diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml index ca9fea4..807d26d 100644 --- a/contracts/Scarb.toml +++ b/contracts/Scarb.toml @@ -24,6 +24,3 @@ test = "snforge test --features test_contracts" [[target.starknet-contract]] sierra = true casm = true - -[[test]] -name = "xenia_unittest" diff --git a/contracts/tests/lib.cairo b/contracts/tests/lib.cairo new file mode 100644 index 0000000..4b511f6 --- /dev/null +++ b/contracts/tests/lib.cairo @@ -0,0 +1 @@ +mod test_xenia_escrow; From 6b85bbc8324209012d3e717b3f964a4afe68dfdb Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 11:51:47 +0100 Subject: [PATCH 06/26] Write up the client fixes needed against XeniaEscrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes in src/lib/xenia/, with the reasoning and the exact code. Two of them are the PRD's fault rather than the client's: §5.1/§5.2 show 6-element calldata against a 10-parameter entrypoint, and §4.4.6 specifies a refund check that cannot be implemented. Both are noted as such. Also records what has been confirmed since the PRD was written: the withdraw-then-invoke shape with an empty span is valid, the mainnet pool fee is 6 STRK per transaction, and every listed mainnet transaction must be tied to XeniaEscrow, which makes refund a demo path. --- contracts/CLIENT-FIXES.md | 155 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 contracts/CLIENT-FIXES.md diff --git a/contracts/CLIENT-FIXES.md b/contracts/CLIENT-FIXES.md new file mode 100644 index 0000000..e125cef --- /dev/null +++ b/contracts/CLIENT-FIXES.md @@ -0,0 +1,155 @@ +# 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 deployed nowhere yet, so nothing here is urgent in the "production is broken" +sense — but it is worth doing before more UI is built on top. + +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. From a80e214a9fe821c0234450ada39d4e36706fe0fc Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 12:29:36 +0100 Subject: [PATCH 07/26] Load deploy credentials from a gitignored .env The deploy script needed a private key in the environment, which invited pasting one somewhere it should not go. Credentials now live in contracts/scripts/.env, loaded by Node's --env-file, and .env is already covered by the repo's ignore rules. .env.example carries the verified Sepolia and mainnet values with mainnet commented out, so the default path is a testnet rehearsal. Sepolia RPC verified reachable and the Sepolia pool confirmed deployed at 0x0254a6...0d91. --- contracts/scripts/.env.example | 20 ++++++++++++++++++++ contracts/scripts/declare-and-deploy.mjs | 3 ++- contracts/scripts/package.json | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 contracts/scripts/.env.example diff --git a/contracts/scripts/.env.example b/contracts/scripts/.env.example new file mode 100644 index 0000000..2f0cb77 --- /dev/null +++ b/contracts/scripts/.env.example @@ -0,0 +1,20 @@ +# 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 + +# ── 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= diff --git a/contracts/scripts/declare-and-deploy.mjs b/contracts/scripts/declare-and-deploy.mjs index 600fe25..8fed162 100644 --- a/contracts/scripts/declare-and-deploy.mjs +++ b/contracts/scripts/declare-and-deploy.mjs @@ -11,7 +11,8 @@ * node declare-and-deploy.mjs --dry-run # compute the class hash, submit nothing * node declare-and-deploy.mjs # declare + deploy * - * Environment (a .env is not read — export these, or prefix the command): + * 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 diff --git a/contracts/scripts/package.json b/contracts/scripts/package.json index fd46667..2f0c727 100644 --- a/contracts/scripts/package.json +++ b/contracts/scripts/package.json @@ -5,8 +5,8 @@ "type": "module", "description": "Declare and deploy XeniaEscrow. Kept separate from the client so contract tooling does not depend on the Next.js build.", "scripts": { - "deploy": "node declare-and-deploy.mjs", - "deploy:dry": "node declare-and-deploy.mjs --dry-run" + "deploy": "node --env-file=.env declare-and-deploy.mjs", + "deploy:dry": "node --env-file=.env declare-and-deploy.mjs --dry-run" }, "dependencies": { "starknet": "^10.4.0" From 68b1c32f6d531dce02b32c7309c7a635e086d537 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 13:14:15 +0100 Subject: [PATCH 08/26] Add pre-flight checks for the deploying account Three things waste a deploy attempt, and all three are cheaper to catch before submitting: the account not being deployed on-chain, the private key not controlling the address, and the account being unable to pay for the declare. The first is the common one. Starknet accounts are contracts, so a wallet shows a usable address and a faucet will happily fund it while the chain still has nothing there. The balance is reported either way, because "funded but not deployed" and "not funded" need different fixes and look identical otherwise. Run before both the Sepolia and the mainnet deploy. --- contracts/scripts/package.json | 1 + contracts/scripts/preflight.mjs | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 contracts/scripts/preflight.mjs diff --git a/contracts/scripts/package.json b/contracts/scripts/package.json index 2f0c727..2cf3170 100644 --- a/contracts/scripts/package.json +++ b/contracts/scripts/package.json @@ -5,6 +5,7 @@ "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" }, 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); From 20b1e1745e844fa5611fa8fb8a6d4d26a06e960d Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 13:56:49 +0100 Subject: [PATCH 09/26] Support Argent v0.4 signatures in the deploy script, and fix the Account call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the first real Sepolia run surfaced, neither of which the dry run could have caught: starknet.js v10 replaced the positional Account constructor with an options object. The old form binds `provider` to `options`, leaving `address` undefined, and fails inside the library on `address.toLowerCase()` — which reads like a bug in the caller. Argent/Ready accounts from v0.4 validate against Array, not a bare [r, s], so the default signer is rejected with `argent/invalid-signature-length`. ArgentV4Signer emits the five-felt encoding [1, 0, pubkey, r, s]. Every signing path funnels through signRaw, so the one override covers declare, deploy and invoke. Selected with ACCOUNT_TYPE=argent; `standard` keeps the plain form for OpenZeppelin-style accounts. Note this is not sufficient for an account with a guardian set: Argent Shield requires an owner and a guardian signature, and the guardian key is held by Argent, so such an account cannot be driven from a script at all. --- contracts/scripts/.env.example | 4 +++ contracts/scripts/declare-and-deploy.mjs | 41 +++++++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/contracts/scripts/.env.example b/contracts/scripts/.env.example index 2f0cb77..1a943b9 100644 --- a/contracts/scripts/.env.example +++ b/contracts/scripts/.env.example @@ -18,3 +18,7 @@ POOL_ADDRESS=0x0254a6b2997ef52e9f830ce1f543f6b29768295e8d17e2267d672c552cfe0d91 # 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 index 8fed162..edf7cd8 100644 --- a/contracts/scripts/declare-and-deploy.mjs +++ b/contracts/scripts/declare-and-deploy.mjs @@ -24,7 +24,31 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Account, CallData, RpcProvider, constants, hash } from 'starknet'; +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'); @@ -95,11 +119,18 @@ const main = async () => { } } - const account = new Account( + // 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, - required('DEPLOYER_ADDRESS'), - required('DEPLOYER_PRIVATE_KEY'), - ); + 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 From 5c9593fff433e826edfc27c2610ee4af5e833c7b Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 16:24:27 +0100 Subject: [PATCH 10/26] Deploy XeniaEscrow to Sepolia and record the rehearsal Live at 0x4564195cae51bab74923df3029c43a4f27149b361488235c7e3ff1ea1374b81, pointing at the Sepolia pool. Verified after the fact rather than assumed: privacy_contract() returns the expected pool, and get_claim on an unknown commitment returns the all-zero not-found sentinel. new-deployer.mjs creates a purpose-built OpenZeppelin deployer account, because a wallet account with Argent Shield carries a guardian and needs a second signature held by Argent, so it cannot be driven from a script. The generated key is written to .env and never printed. DEPLOYMENTS.md records the addresses, the four failure modes this rehearsal caught before mainnet, and the mainnet checklist. --- contracts/DEPLOYMENTS.md | 44 ++++++++++++ contracts/scripts/new-deployer.mjs | 105 +++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 contracts/DEPLOYMENTS.md create mode 100644 contracts/scripts/new-deployer.mjs diff --git a/contracts/DEPLOYMENTS.md b/contracts/DEPLOYMENTS.md new file mode 100644 index 0000000..e967014 --- /dev/null +++ b/contracts/DEPLOYMENTS.md @@ -0,0 +1,44 @@ +# Deployments + +## Sepolia — 2026-08-25 + +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/scripts/new-deployer.mjs b/contracts/scripts/new-deployer.mjs new file mode 100644 index 0000000..342f296 --- /dev/null +++ b/contracts/scripts/new-deployer.mjs @@ -0,0 +1,105 @@ +/** + * 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)); +const ENV = resolve(HERE, '.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.'); From 755c6ee4864c7eacd3b2404913e04e8a587618ae Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 20:28:46 +0100 Subject: [PATCH 11/26] Answer two of the three open questions from mainnet itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than wait on support, pull every ViewingKeySet event the pool has emitted and look at what else happened inside those transactions. Registration does bundle into a larger transaction, and is doing so in production today — but every bundled case rides alongside a Deposit. None is a pure receive, which is Xenia's shape. That narrows the unknown considerably and supports bundling a small self-deposit into the claim as a workaround. The fee is fronted publicly by a relayer and then reimbursed out of the pool, which is why a claimant needs no public STRK but something must still fund the outbound 6 STRK. In the transaction traced, the user's own deposit covered it. Standalone registrations are plain invokes calling apply_actions directly on the pool, so a dapp can register a user itself. The two-step fallback is two clicks on our page rather than a trip into the wallet's settings, which was the difference that mattered. --- contracts/ONCHAIN-FINDINGS.md | 86 +++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 contracts/ONCHAIN-FINDINGS.md diff --git a/contracts/ONCHAIN-FINDINGS.md b/contracts/ONCHAIN-FINDINGS.md new file mode 100644 index 0000000..9baccd9 --- /dev/null +++ b/contracts/ONCHAIN-FINDINGS.md @@ -0,0 +1,86 @@ +# 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. From bd942b309cdd379d745058dd68c74970f92b8f50 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 20:36:30 +0100 Subject: [PATCH 12/26] Settle the fee question from the pool's balance invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining unknown split into a wallet half and a protocol half. The protocol half needs no browser and no support reply. Pool transactions track a per-token running balance: deposit and use_note add, withdraw and create_note subtract, and the total must be exactly zero, with checked_sub panicking on the way. invoke_external has no balance effect at all — the escrow's OpenNoteDeposit is applied server-side, outside this accounting. So a first-time claimant's transaction has a 6 STRK fee withdrawal and no inflow to balance it, and the pool rejects it regardless of which wallet built it. The mainnet transaction traced earlier fits the invariant exactly: +8 deposit, -2 note, -6 fee. That leaves exactly two viable claim shapes: a sponsored flow with no fee withdrawal at all, or bundling a deposit that covers the fee. The second is buildable without anyone's permission and is already a proven shape on mainnet. --- contracts/ONCHAIN-FINDINGS.md | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/contracts/ONCHAIN-FINDINGS.md b/contracts/ONCHAIN-FINDINGS.md index 9baccd9..40b7687 100644 --- a/contracts/ONCHAIN-FINDINGS.md +++ b/contracts/ONCHAIN-FINDINGS.md @@ -84,3 +84,62 @@ Items 1–3 are measured from mainnet, not inferred from documentation. The samp 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. From dabf729e35906c62b12f667968a0ed1e2a26c6da Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 21:57:03 +0100 Subject: [PATCH 13/26] Rule out sponsorship from measurement rather than assumption Classified 18 real pool transactions carrying Deposit or OpenNoteCreated by whether they emit a fee-reimbursement Withdrawal and who paid the fee. All 18 were relayer-submitted and reimbursed from the pool. None sponsored, none self-paid. With the three standalone registrations examined earlier, the split is clean: registration alone is self-submitted and the fee paid publicly with no withdrawal, while every note-bearing transaction goes through a relayer that the pool reimburses. A claim is note-bearing, so the withdrawal is not optional in practice, and it is what needs an inflow. That leaves bundling a deposit as the only demonstrated path, which is what all 18 of those transactions are doing. --- contracts/ONCHAIN-FINDINGS.md | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/contracts/ONCHAIN-FINDINGS.md b/contracts/ONCHAIN-FINDINGS.md index 40b7687..6f42101 100644 --- a/contracts/ONCHAIN-FINDINGS.md +++ b/contracts/ONCHAIN-FINDINGS.md @@ -143,3 +143,42 @@ 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. From 793b28b8e35e3d8808b7ec8966af46db0c530844 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 22:47:07 +0100 Subject: [PATCH 14/26] Add a read-only wallet probe page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the three things we cannot determine from a script, without spending anything: does this wallet implement the STRK20 methods, has this account ever registered, and what does the pool currently charge. wallet_strk20Balances is read-only and safe against any wallet; a wallet that answers "not implemented" has told us the Wallet API route is closed to it. The registration check reads the pool's stored viewing key directly over RPC, so a zero means the account is still usable for the register-and-claim test. Plain HTML with no dependencies — open it in the browser that has the wallet extension. Nothing is submitted. --- contracts/scripts/probe.html | 213 +++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 contracts/scripts/probe.html diff --git a/contracts/scripts/probe.html b/contracts/scripts/probe.html new file mode 100644 index 0000000..1ef99d1 --- /dev/null +++ b/contracts/scripts/probe.html @@ -0,0 +1,213 @@ + + + + + +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…
+
+
+ + + + From 3cd8771ab9e3162cedf7b1cfff039470016127c8 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 22:52:18 +0100 Subject: [PATCH 15/26] Detect wallets that use the Wallet Standard, not just window injection The probe reported no wallet with Ready installed. Two reasons: newer builds announce themselves through the Wallet Standard event protocol rather than writing window.starknet_, and extensions generally do not inject into file:// URLs at all. Discovery now covers both mechanisms and rescans twice after load, since registration can land a beat late. A diagnostics panel reports the page origin, any legacy objects, any wallets that registered, and their feature names, so a failed detection still says something useful. The file:// case gets its own message rather than looking like a missing extension. --- contracts/scripts/probe.html | 65 +++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/contracts/scripts/probe.html b/contracts/scripts/probe.html index 1ef99d1..97841ad 100644 --- a/contracts/scripts/probe.html +++ b/contracts/scripts/probe.html @@ -56,8 +56,10 @@

Xenia wallet probe

1 · Wallet

+
+
@@ -98,12 +100,34 @@

4 · Verdict

const $ = (id) => document.getElementById(id); const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); -/** Wallets inject themselves as window.starknet_*; take whatever is there. */ +/** + * Two discovery mechanisms, because wallets moved between them. + * + * Legacy: the wallet writes itself onto window as `starknet_`. + * Wallet Standard: the app announces itself and wallets call back to register. Newer builds of + * Ready and Braavos use this one only, which is why checking `window.starknet_*` alone finds + * nothing. + */ +const standardWallets = []; +(function discoverStandard() { + const api = { + register: (...ws) => { standardWallets.push(...ws); return () => {}; }, + }; + window.addEventListener('wallet-standard:register-wallet', (e) => { + try { e.detail(api); } catch (err) { /* ignore a wallet that misbehaves */ } + }); + window.dispatchEvent(new CustomEvent('wallet-standard:app-ready', { detail: api })); +})(); + function findWallets() { - return Object.keys(window) - .filter((k) => k.startsWith('starknet_')) - .map((k) => ({ key: k, obj: window[k] })) + const legacy = Object.keys(window) + .filter((k) => k.startsWith('starknet')) + .map((k) => ({ key: k, obj: window[k], kind: 'legacy' })) .filter((w) => w.obj && typeof w.obj === 'object'); + const standard = standardWallets.map((w) => ({ + key: w.name ?? 'unnamed', obj: w, kind: 'standard', + })); + return [...legacy, ...standard]; } async function rpc(url, method, params) { @@ -116,11 +140,34 @@

4 · Verdict

return j.result; } -const found = findWallets(); -$('detected').textContent = found.length - ? `detected: ${found.map((w) => w.key.replace('starknet_', '')).join(', ')}` - : 'no Starknet wallet detected in this browser'; -if (!found.length) $('connect').disabled = true; +let found = []; +function refreshDetection() { + found = findWallets(); + const isFile = location.protocol === 'file:'; + if (found.length) { + $('detected').innerHTML = 'detected: ' + found + .map((w) => `${esc(w.key.replace('starknet_', ''))} (${w.kind})`) + .join(', '); + $('connect').disabled = false; + } else { + $('detected').innerHTML = 'no Starknet wallet detected' + (isFile + ? ' — this page is open as a file:// URL, and extensions usually do not inject there. Serve it over http instead.' + : ' — is the extension unlocked and enabled for this site?'); + $('connect').disabled = true; + } + $('diag').innerHTML = ` +
+
page origin
${esc(location.origin || 'file://')}
+
legacy objects
${esc(Object.keys(window).filter((k) => k.startsWith('starknet')).join(', ') || 'none')}
+
wallet-standard
${esc(standardWallets.map((w) => w.name).join(', ') || 'none registered')}
+
features
${esc(standardWallets.map((w) => `${w.name}: ${Object.keys(w.features || {}).join(' ')}`).join(' | ') || '—')}
+
`; +} +refreshDetection(); +// Wallets sometimes register a beat after page load. +setTimeout(refreshDetection, 400); +setTimeout(refreshDetection, 1500); +$('rescan').addEventListener('click', refreshDetection); $('connect').addEventListener('click', async () => { $('connect').disabled = true; From 6be185e752a28e2c3f70a4b25ab5b5119c4314ff Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 22:54:33 +0100 Subject: [PATCH 16/26] Tell a Starknet wallet apart from any other registered wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe found Brave Wallet, which registers with only solana:* features, picked it as the only candidate, and reported "wallet.request is not a function" — which reads like a bug in the page rather than the actual finding, that no Starknet wallet is present. Wallets are now classified before use: legacy injections must expose request or enable, and Wallet Standard entries must carry a starknet: feature. When wallets are found but none are Starknet-capable, the page says exactly that and names what it did find. --- contracts/scripts/probe.html | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/contracts/scripts/probe.html b/contracts/scripts/probe.html index 97841ad..b784f42 100644 --- a/contracts/scripts/probe.html +++ b/contracts/scripts/probe.html @@ -119,6 +119,16 @@

4 · Verdict

window.dispatchEvent(new CustomEvent('wallet-standard:app-ready', { detail: api })); })(); +/** + * A registered wallet is not necessarily a Starknet wallet. Brave Wallet, for instance, registers + * with only `solana:*` features — talking to it as if it were Starknet produces a confusing + * "wallet.request is not a function" rather than a useful answer. Classify first. + */ +function isStarknetCapable(w) { + if (w.kind === 'legacy') return typeof w.obj?.request === 'function' || typeof w.obj?.enable === 'function'; + return Object.keys(w.obj?.features ?? {}).some((f) => f.startsWith('starknet:')); +} + function findWallets() { const legacy = Object.keys(window) .filter((k) => k.startsWith('starknet')) @@ -127,7 +137,7 @@

4 · Verdict

const standard = standardWallets.map((w) => ({ key: w.name ?? 'unnamed', obj: w, kind: 'standard', })); - return [...legacy, ...standard]; + return [...legacy, ...standard].map((w) => ({ ...w, starknet: isStarknetCapable(w) })); } async function rpc(url, method, params) { @@ -142,9 +152,16 @@

4 · Verdict

let found = []; function refreshDetection() { - found = findWallets(); + const all = findWallets(); + found = all.filter((w) => w.starknet); + const others = all.filter((w) => !w.starknet); const isFile = location.protocol === 'file:'; - if (found.length) { + if (!found.length && others.length) { + $('detected').innerHTML = 'no Starknet wallet detected — found ' + + others.map((w) => esc(w.key)).join(', ') + + ', but none of them expose Starknet features. Is Ready X installed, enabled and unlocked in this browser?'; + $('connect').disabled = true; + } else if (found.length) { $('detected').innerHTML = 'detected: ' + found .map((w) => `${esc(w.key.replace('starknet_', ''))} (${w.kind})`) .join(', '); @@ -161,6 +178,7 @@

4 · Verdict

legacy objects
${esc(Object.keys(window).filter((k) => k.startsWith('starknet')).join(', ') || 'none')}
wallet-standard
${esc(standardWallets.map((w) => w.name).join(', ') || 'none registered')}
features
${esc(standardWallets.map((w) => `${w.name}: ${Object.keys(w.features || {}).join(' ')}`).join(' | ') || '—')}
+
starknet-capable
${esc(findWallets().filter((w) => w.starknet).map((w) => w.key).join(', ') || 'none')}
`; } refreshDetection(); From 3c5631fc7195de4fbadbb9740f1d693018d82483 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 22:58:27 +0100 Subject: [PATCH 17/26] Find wallets that install themselves as non-enumerable properties The probe reported no Starknet wallet on a browser where Ready X was installed and unlocked. The fault was the detection, not the browser: Object.keys returns only enumerable properties, and wallet extensions routinely define themselves with enumerable: false, so the scan could not see a wallet that was present. Detection now uses getOwnPropertyNames and additionally probes the known wallet keys by name, guarding each read in case a getter throws. The diagnostics report both the enumerable and the full property scan, so the two can be compared when a wallet still does not appear. --- contracts/scripts/probe.html | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/contracts/scripts/probe.html b/contracts/scripts/probe.html index b784f42..34890aa 100644 --- a/contracts/scripts/probe.html +++ b/contracts/scripts/probe.html @@ -129,9 +129,30 @@

4 · Verdict

return Object.keys(w.obj?.features ?? {}).some((f) => f.startsWith('starknet:')); } +/** + * `Object.keys(window)` returns only *enumerable* properties, and wallet extensions routinely + * install themselves with `Object.defineProperty(..., { enumerable: false })`. Enumerating that way + * reports "no wallet" while the wallet is sitting right there. Use getOwnPropertyNames, and also + * probe the known names directly in case the property is on a prototype or a proxy. + */ +const KNOWN_KEYS = [ + 'starknet', 'starknet_argentX', 'starknet_ready', 'starknet_braavos', 'starknet_okxwallet', + 'starknet_keplr', 'starknet_metamask', 'starknet_fordefi', 'starknet_bitkeep', +]; + +function legacyKeys() { + const seen = new Set(); + try { + for (const k of Object.getOwnPropertyNames(window)) if (k.startsWith('starknet')) seen.add(k); + } catch { /* some environments restrict this */ } + for (const k of KNOWN_KEYS) { + try { if (window[k]) seen.add(k); } catch { /* ignore a throwing getter */ } + } + return [...seen]; +} + function findWallets() { - const legacy = Object.keys(window) - .filter((k) => k.startsWith('starknet')) + const legacy = legacyKeys() .map((k) => ({ key: k, obj: window[k], kind: 'legacy' })) .filter((w) => w.obj && typeof w.obj === 'object'); const standard = standardWallets.map((w) => ({ @@ -175,7 +196,8 @@

4 · Verdict

$('diag').innerHTML = `
page origin
${esc(location.origin || 'file://')}
-
legacy objects
${esc(Object.keys(window).filter((k) => k.startsWith('starknet')).join(', ') || 'none')}
+
legacy (enumerable)
${esc(Object.keys(window).filter((k) => k.startsWith('starknet')).join(', ') || 'none')}
+
legacy (all props)
${esc(legacyKeys().join(', ') || 'none')}
wallet-standard
${esc(standardWallets.map((w) => w.name).join(', ') || 'none registered')}
features
${esc(standardWallets.map((w) => `${w.name}: ${Object.keys(w.features || {}).join(' ')}`).join(' | ') || '—')}
starknet-capable
${esc(findWallets().filter((w) => w.starknet).map((w) => w.key).join(', ') || 'none')}
From c29a52ce8f14a28133d42e1e5d6685edc2aded25 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 23:01:42 +0100 Subject: [PATCH 18/26] Stop reading a payload complaint as an unimplemented method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe called wallet_strk20Balances with no parameters, got INVALID_REQUEST_PAYLOAD, and concluded the wallet could not drive a claim. That is backwards: the wallet recognised the method and rejected the arguments. A missing method fails differently. It now checks the Wallet API version first — STRK20 rides on 0.10.3 and later, and Ready reports exactly that — then tries the plausible payload shapes for wallet_strk20Balances and reports which one is accepted. A payload complaint is recorded as proof the method exists rather than proof it does not. --- contracts/scripts/probe.html | 53 +++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/contracts/scripts/probe.html b/contracts/scripts/probe.html index 34890aa..067e818 100644 --- a/contracts/scripts/probe.html +++ b/contracts/scripts/probe.html @@ -238,23 +238,62 @@

4 · Verdict

chain
${esc(chainId ?? '—')} ${net ? `(${net.name})` : '(unrecognised)'}
`; // ── 2. STRK20 support ──────────────────────────────────────────────────────────────── - let speaksStrk20 = false; + const STRK_TOKEN = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; const attempts = []; - for (const type of ['wallet_strk20Balances', 'wallet_supportedSpecs', 'wallet_supportedWalletApi']) { + let speaksStrk20 = false; + let apiVersions = []; + + for (const type of ['wallet_supportedSpecs', 'wallet_supportedWalletApi']) { try { const res = await wallet.request({ type }); - attempts.push([type, 'ok', JSON.stringify(res).slice(0, 300)]); - if (type === 'wallet_strk20Balances') speaksStrk20 = true; + attempts.push([type, 'ok', JSON.stringify(res)]); + if (Array.isArray(res)) apiVersions = apiVersions.concat(res); + } catch (e) { attempts.push([type, 'error', (e && e.message) || String(e)]); } + } + + // STRK20 rides on Wallet API 0.10.3 and later. + const hasStrk20Api = apiVersions.some((v) => { + const [maj, min, pat] = String(v).split('.').map(Number); + return maj > 0 || min > 10 || (min === 10 && pat >= 3); + }); + + /** + * An empty payload returns INVALID_REQUEST_PAYLOAD — which means the method exists and the + * arguments were wrong, not that the wallet lacks it. Try the plausible shapes and report which + * one the wallet accepts. + */ + const shapes = [ + ['no params', undefined], + ['{tokenAddresses}', { tokenAddresses: [STRK_TOKEN] }], + ['{tokens}', { tokens: [STRK_TOKEN] }], + ['{addresses}', { addresses: [STRK_TOKEN] }], + ['array', [STRK_TOKEN]], + ['{token}', { token: STRK_TOKEN }], + ]; + for (const [label, params] of shapes) { + try { + const res = await wallet.request(params === undefined + ? { type: 'wallet_strk20Balances' } + : { type: 'wallet_strk20Balances', params }); + attempts.push([`wallet_strk20Balances ${label}`, 'ok', JSON.stringify(res).slice(0, 300)]); + speaksStrk20 = true; + break; } catch (e) { - attempts.push([type, 'error', (e && e.message) || String(e)]); + const msg = (e && e.message) || String(e); + attempts.push([`wallet_strk20Balances ${label}`, 'error', msg]); + // A payload complaint still proves the method is implemented. + if (/PAYLOAD|param|argument/i.test(msg)) speaksStrk20 = true; } } $('strk20').innerHTML = attempts.map(([t, s, d]) => `
${esc(t)}${s}
${esc(d)}
`).join(''); + notes.push(hasStrk20Api + ? `Wallet supports Wallet API ${apiVersions.join(', ')} — 0.10.3+ is what carries STRK20, so the Wallet API route is open.` + : `Wallet reports Wallet API ${apiVersions.join(', ') || 'unknown'} — STRK20 needs 0.10.3 or later.`); notes.push(speaksStrk20 - ? 'Wallet answers wallet_strk20Balances — the Wallet API route is open.' - : 'Wallet did not answer wallet_strk20Balances. Check the error above: "not implemented" means this wallet cannot drive the claim.'); + ? 'wallet_strk20Balances is implemented (a payload complaint still proves the method exists).' + : 'wallet_strk20Balances did not respond to any payload shape — treat the Wallet API route as unconfirmed.'); // ── 3. Registration + fee, read from the pool ──────────────────────────────────────── if (!net) { From 710b4f96481d1c2b0b9128f4ce4a038a3ae0e3a1 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Tue, 25 Aug 2026 23:53:45 +0100 Subject: [PATCH 19/26] Let a deposit pre-fund the claimant, without breaking the interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claimant holding nothing cannot claim: the pool charges a fee in STRK and its balance invariant requires that outflow to be matched by an inflow in the same transaction. Sending them the fee ahead of time supplies it. The funds come from the escrow rather than the sender's own address, which matters more than it looks. Had the sender funded the claimant directly, the public trail sender -> claimant would expose exactly the edge Xenia exists to hide. Paying out of a shared contract leaks no such thing. Two parameters already passed as zero on Deposit — claimant and note_id — carry the recipient and the amount instead of appending new ones, because the pool deserialises calldata positionally and appending would change its length and break every existing caller. Zero keeps the old behaviour exactly, so this is opt-in and the client's calldata shape does not move. The fee token is a constructor argument rather than a constant, since a claim denominated in USDC still owes its fee in STRK. ClaimPrefunded is a separate event so ClaimCreated keeps the shape the client already reads. 17 tests, including that pre-funding transfers and emits, and that a deposit without it is byte-for-byte unchanged. --- contracts/scripts/.env.example | 2 + contracts/scripts/declare-and-deploy.mjs | 4 +- contracts/src/xenia_escrow.cairo | 55 ++++++++++++++++- contracts/tests/test_xenia_escrow.cairo | 75 +++++++++++++++++++++++- 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/contracts/scripts/.env.example b/contracts/scripts/.env.example index 1a943b9..015a88f 100644 --- a/contracts/scripts/.env.example +++ b/contracts/scripts/.env.example @@ -7,6 +7,8 @@ # ── 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 diff --git a/contracts/scripts/declare-and-deploy.mjs b/contracts/scripts/declare-and-deploy.mjs index edf7cd8..9e46ccf 100644 --- a/contracts/scripts/declare-and-deploy.mjs +++ b/contracts/scripts/declare-and-deploy.mjs @@ -93,7 +93,9 @@ const main = async () => { 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'); @@ -146,7 +148,7 @@ const main = async () => { console.log('\n Deploying…'); const deployed = await account.deployContract({ classHash: declared.class_hash ?? classHash, - constructorCalldata: CallData.compile([poolAddress]), + constructorCalldata: CallData.compile([poolAddress, feeToken]), }); console.log(` deploy tx ${deployed.transaction_hash}`); await provider.waitForTransaction(deployed.transaction_hash); diff --git a/contracts/src/xenia_escrow.cairo b/contracts/src/xenia_escrow.cairo index 56fbe43..ef927c8 100644 --- a/contracts/src/xenia_escrow.cairo +++ b/contracts/src/xenia_escrow.cairo @@ -58,7 +58,19 @@ pub trait IXeniaEscrow { /// 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`, `sig_r`, `sig_s`, `note_id` — ignored. + /// * `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 @@ -105,6 +117,7 @@ pub mod errors { 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)`. @@ -145,6 +158,9 @@ pub mod XeniaEscrow { 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 @@ -154,6 +170,7 @@ pub mod XeniaEscrow { #[derive(Drop, starknet::Event)] pub enum Event { ClaimCreated: ClaimCreated, + ClaimPrefunded: ClaimPrefunded, ClaimRedeemed: ClaimRedeemed, ClaimRefunded: ClaimRefunded, } @@ -167,6 +184,17 @@ pub mod XeniaEscrow { 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] @@ -184,8 +212,11 @@ pub mod XeniaEscrow { } #[constructor] - fn constructor(ref self: ContractState, privacy_contract: ContractAddress) { + 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)] @@ -233,9 +264,27 @@ pub mod XeniaEscrow { 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". - [].span() + // + // 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. diff --git a/contracts/tests/test_xenia_escrow.cairo b/contracts/tests/test_xenia_escrow.cairo index 49223ca..faf2de7 100644 --- a/contracts/tests/test_xenia_escrow.cairo +++ b/contracts/tests/test_xenia_escrow.cairo @@ -6,6 +6,7 @@ //! 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::{ @@ -13,7 +14,7 @@ use snforge_std::{ start_cheat_block_timestamp_global, start_cheat_caller_address, stop_cheat_caller_address, }; use starknet::ContractAddress; -use xenia::xenia_escrow::XeniaEscrow::{ClaimCreated, ClaimRedeemed, ClaimRefunded}; +use xenia::xenia_escrow::XeniaEscrow::{ClaimCreated, ClaimPrefunded, ClaimRedeemed, ClaimRefunded}; use xenia::xenia_escrow::{ IXeniaEscrowDispatcher, IXeniaEscrowDispatcherTrait, XeniaOperation, claim_message, compute_commitment, refund_message, @@ -25,6 +26,9 @@ 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; @@ -44,7 +48,14 @@ fn setup() -> (IXeniaEscrowDispatcher, ContractAddress) { let (token, _) = token_class.deploy(@token_args).unwrap(); let escrow_class = declare("XeniaEscrow").unwrap().contract_class(); - let (escrow, _) = escrow_class.deploy(@array![POOL]).unwrap(); + // 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) } @@ -386,3 +397,63 @@ fn a_claim_signature_cannot_be_replayed_as_a_refund() { '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"); +} From eeccca2353a32b7611aac31087ec3b5242aab2b9 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 00:10:44 +0100 Subject: [PATCH 20/26] Check that the client and the contract agree, in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client derives the link key, hashes the commitment and signs the claim in JavaScript; the contract recomputes all of it in Cairo. A disagreement over a domain tag, a hash-padding convention or an argument order reverts every claim — and would do so first on mainnet, with real money, having passed every isolated test on both sides. So the reference values are produced by JavaScript, using the same calls src/lib/xenia/crypto.ts makes, and asserted in Cairo. CI becomes the cross-language check. js-reference.mjs regenerates them if the derivation changes. Five cases: the commitment, the claim message and the refund message all match; a signature made by ec.starkCurve.sign satisfies check_ecdsa_signature; and that same signature fails against the refund message, so domain separation is doing its job. --- contracts/scripts/js-reference.mjs | 43 ++++++++++++++++ contracts/tests/lib.cairo | 1 + contracts/tests/test_js_interop.cairo | 70 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 contracts/scripts/js-reference.mjs create mode 100644 contracts/tests/test_js_interop.cairo 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/tests/lib.cairo b/contracts/tests/lib.cairo index 4b511f6..49206f6 100644 --- a/contracts/tests/lib.cairo +++ b/contracts/tests/lib.cairo @@ -1 +1,2 @@ +mod test_js_interop; 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"); +} From 2e6f25249d89ea6a6128597c68be49cf8cbbd124 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 00:14:22 +0100 Subject: [PATCH 21/26] Redeploy to Sepolia with the fee token and pre-funding Supersedes the 25 August deployment, whose constructor predates the fee token argument. Verified live rather than assumed: the pool address is right, an unknown commitment returns the not-found sentinel, and the deployed ABI carries ClaimPrefunded alongside the original three events. --- contracts/DEPLOYMENTS.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/contracts/DEPLOYMENTS.md b/contracts/DEPLOYMENTS.md index e967014..bf84ebd 100644 --- a/contracts/DEPLOYMENTS.md +++ b/contracts/DEPLOYMENTS.md @@ -1,6 +1,24 @@ # Deployments -## Sepolia — 2026-08-25 +## 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. From 86c6b2fe119e27cf52dbe752f6972f6d98a07244 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 00:21:26 +0100 Subject: [PATCH 22/26] Retry the build, and point the cache at our lock file A commit touching only DEPLOYMENTS.md failed the build step while the commit before it, with identical contract code, passed. That is the registry deciding whether the suite runs, so the step now retries three times before giving up. setup-scarb also reported "failed to find Scarb.lock" on every run, because the lock lives in contracts/ rather than the repo root. Pointing it there restores dependency caching, which makes the fetch it was failing on less likely in the first place. --- .github/workflows/contracts.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 2505c5a..f8c1687 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -27,9 +27,12 @@ jobs: 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: @@ -41,8 +44,17 @@ jobs: - 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: scarb 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")`. From 9b31c9f183529b169a17d86378ee5e1acbe63f93 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 00:32:36 +0100 Subject: [PATCH 23/26] Drive the whole lifecycle the way the pool drives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing suite proves the escrow's logic but cheats the caller address and calls through a typed dispatcher, which leaves the parts of the integration a client actually trips over untested: flat calldata deserialised positionally into ten parameters, a returned span the pool has to deserialise, and an approval the pool then pulls against. MockPrivacyPool does the real handshake — transfers the input tokens, calls privacy_invoke by INVOKE_SELECTOR with a raw Span, deserialises Span, and transfer_froms what it was told to credit. The calldata arrays in the tests are byte-for-byte what CLIENT-FIXES.md tells the client to send, so a shape mismatch fails here rather than on mainnet. Four cases: a deposit parks the funds and returns an empty span; a claim credits exactly one note and the pool can pull it, which only works because the escrow approved first; a refund after expiry returns the funds the same way; and a claim redirected to another address reverts. 26 tests. The mock stays behind the test_contracts feature — a clean build still produces XeniaEscrow alone. --- contracts/src/mocks.cairo | 72 ++++++++ contracts/tests/lib.cairo | 1 + contracts/tests/test_pool_handshake.cairo | 201 ++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 contracts/tests/test_pool_handshake.cairo diff --git a/contracts/src/mocks.cairo b/contracts/src/mocks.cairo index 2b1ae89..5a2689e 100644 --- a/contracts/src/mocks.cairo +++ b/contracts/src/mocks.cairo @@ -68,3 +68,75 @@ pub mod MockERC20 { } } } + +/// 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/tests/lib.cairo b/contracts/tests/lib.cairo index 49206f6..f115f48 100644 --- a/contracts/tests/lib.cairo +++ b/contracts/tests/lib.cairo @@ -1,2 +1,3 @@ mod test_js_interop; +mod test_pool_handshake; mod test_xenia_escrow; 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(), + ); +} From d0bf9dadbb7cead625eee58ee45f2bfe4a96b64d Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 00:56:21 +0100 Subject: [PATCH 24/26] Deploy XeniaEscrow to mainnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live at 0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095, class hash identical to the Sepolia deployment, so the code that passed the suite is exactly what is on chain. Verified after the fact rather than assumed: the chain is SN_MAIN, privacy_contract() returns the mainnet pool — the immutable setting that would have bricked every call had it been wrong — get_claim returns the not-found sentinel, and all four events are in the ABI. Cost about 10.3 STRK. Worth recording that the declare reserves a ceiling near 21 STRK before it will run even though it charges roughly half: fund above the ceiling, not above the expected cost. strk20.json now lists the contract. Listing it raises the bar on the transactions: each one must also be tied to this address, so a plain shield will not count towards the three. --- contracts/DEPLOYMENTS.md | 22 ++++++++++++++++++++++ contracts/scripts/new-deployer.mjs | 4 +++- contracts/scripts/package.json | 4 +++- strk20.json | 4 +++- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/contracts/DEPLOYMENTS.md b/contracts/DEPLOYMENTS.md index bf84ebd..c7a986e 100644 --- a/contracts/DEPLOYMENTS.md +++ b/contracts/DEPLOYMENTS.md @@ -1,5 +1,27 @@ # 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. diff --git a/contracts/scripts/new-deployer.mjs b/contracts/scripts/new-deployer.mjs index 342f296..6bf2261 100644 --- a/contracts/scripts/new-deployer.mjs +++ b/contracts/scripts/new-deployer.mjs @@ -22,7 +22,9 @@ import { fileURLToPath } from 'node:url'; import { Account, RpcProvider, ec, hash, stark } from 'starknet'; const HERE = dirname(fileURLToPath(import.meta.url)); -const ENV = resolve(HERE, '.env'); +/** 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'; diff --git a/contracts/scripts/package.json b/contracts/scripts/package.json index 2cf3170..4928f4e 100644 --- a/contracts/scripts/package.json +++ b/contracts/scripts/package.json @@ -7,7 +7,9 @@ "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" + "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/strk20.json b/strk20.json index 89fcd94..bcd63e4 100644 --- a/strk20.json +++ b/strk20.json @@ -1,6 +1,8 @@ { "transactions": [], - "contracts": [], + "contracts": [ + "0x257082062a074eb79575b859c9b3aadd40a986501223928121b5a1f56627095" + ], "demo_video": "", "demo_url": "" } From 18abffd1cfd79cbaed6dfde75af55d67669e16f1 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 01:00:32 +0100 Subject: [PATCH 25/26] Correct the PRD where it misled, and bring PROGRESS up to date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the four client defects came from this document: §5.1 and §5.2 showed six calldata elements against the ten-parameter entrypoint §4.1 freezes. Jadon built to §5 in good faith. Both now show all ten with unused positions zeroed, and note that Claim and Refund pass the link public key rather than the commitment. §4.4.6 specified a refund check that cannot exist — privacy_invoke is always called by the pool, so get_caller_address() can never be the sender. Replaced with what the contract does and why, and §4.7's refund case reworded to match. §0's three blockers are all settled, two of them by measuring mainnet rather than waiting for a reply, so it now records the answers and what they imply instead of the questions. §3 carries the confirmed validator rule: with contracts non-empty a plain shield does not count, so the three transactions must be create-claim, claim and refund. §5.3 records the route as settled and names what is still unproven. §5.4 carries the measured fee. PROGRESS said it was the 23rd. It now opens with where each half actually stands and logs what this week established. --- PRD.md | 112 ++++++++++++++++++++++++++++++++++++++-------------- PROGRESS.md | 49 ++++++++++++++++++++++- 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/PRD.md b/PRD.md index cdcc280..a9177ce 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. @@ -376,10 +430,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. From 701249dfe413037fe315007e9d1657188dbffc37 Mon Sep 17 00:00:00 2001 From: Sam_Rytech Date: Wed, 26 Aug 2026 01:07:51 +0100 Subject: [PATCH 26/26] Bring the handover docs up to date before integration Four things had gone stale as the work moved on. CLIENT-FIXES said the contract was deployed nowhere, which softened the urgency; it is live on mainnet, and those four fixes are now the only thing between the client and a working transaction. INTERFACE carried the refund authorisation as an open question. It is decided, and the PRD has been reworded to match. INTERFACE also had no deployed addresses and no mention of pre-funding, both of which the client needs. It now opens with the mainnet and Sepolia addresses, marks the two Deposit positions that carry an optional pre-fund, and explains why the fee has to come from the escrow rather than the sender: funding the claimant directly would put the sender-to-recipient edge on chain, which is the one thing Xenia exists to hide. --- contracts/CLIENT-FIXES.md | 6 +++-- contracts/INTERFACE.md | 57 +++++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/contracts/CLIENT-FIXES.md b/contracts/CLIENT-FIXES.md index e125cef..bb7eed3 100644 --- a/contracts/CLIENT-FIXES.md +++ b/contracts/CLIENT-FIXES.md @@ -3,8 +3,10 @@ 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 deployed nowhere yet, so nothing here is urgent in the "production is broken" -sense — but it is worth doing before more UI is built on top. +**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. diff --git a/contracts/INTERFACE.md b/contracts/INTERFACE.md index 849d5a5..c2eb96d 100644 --- a/contracts/INTERFACE.md +++ b/contracts/INTERFACE.md @@ -6,6 +6,16 @@ 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 @@ -42,10 +52,15 @@ Verified against the built ABI in `target/dev/xenia_XeniaEscrow.contract_class.j | 3 | `amount` | `u128` | `0` | `0` | | 4 | `expiry` | absolute unix ts | `0` | `0` | | 5 | `refund_to` | sender's address | `0` | `0` | -| 6 | `claimant` | `0` | claimant address | refunder address | +| 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` | `${openNoteIds[0]}` | `${openNoteIds[0]}` | +| 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 @@ -151,6 +166,38 @@ generated `sk`, so the sender can always sign. Consequences worth being delibera 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". **Sam's call; flagged rather than -decided silently.** PRD §4.4.6 and the §4.7 refund test should be reworded to match whichever -survives. +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.