@ninsei-labs/sdk 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,149 @@
1
+ ## Unreleased
2
+
3
+ ## 0.53.0 — 2026-10-28
4
+
5
+ - A failed `rescanBlockchain` is no longer discarded: `sweep.restore` reports `scanComplete: false` with
6
+ `scanIncompleteReason: "rescan-failed"`, even when the wallet and node heights are equal. A wallet already at the
7
+ tip cannot turn a failed rescan into a successful no-op sync.
8
+ - A `funding-pending` start keeps the sent transaction hash and the predicted escrow on the swap record. A retry of
9
+ the same quote reads that receipt and continues to `xmr-incoming` when it is funded, or returns
10
+ `funding-pending` again; it never builds a second side or sends a second deposit.
11
+ - `scanRange` treats a block without its decoded `json`, a BUSY block, a transaction body count below the wrapper
12
+ count and an extra transaction as unreadable: the block is named in `missingHeights`, not counted as empty or as
13
+ money. Transaction results are mapped back by requested hash, so a partial answer cannot pass as complete.
14
+ - The recovery-key store distinguishes "nothing stored" from "could not read": a throwing read is
15
+ `storage-unavailable { step: "read" }`, corrupt or unknown JSON is `storage-unavailable { step: "parse" }`, and a
16
+ failed write is `storage-unavailable { step: "write" }`. A corrupt record is no longer silently replaced by the
17
+ next start.
18
+ - A new `swaps.start` uses a per-order salt by default: the order's own salt is the v2 recovery salt, and a missing
19
+ salt is drawn before the order context is built. A v1 recovery index is used only when the caller names one
20
+ explicitly, for restoring an old order.
21
+ - The counterparty's view material is normalised once for the guard and the builder: conflicting `viewHalf` /
22
+ `viewHalfClaimer` or `viewPoint` / `viewPub` aliases are refused, the point is derived from the half when only the
23
+ half is present, and a half whose derived point differs from the declared point is refused before funding.
24
+ - `quotes.sale` reads the `{ request: {...} }` envelope as the request, so a claimer named inside it is bound to the
25
+ signed quote. The requested XMR is compared with the signed `xmrAmount`, and a returned order is compared field by
26
+ field with the signed terms instead of only with the derived `context`.
27
+ - The order worker now compares the proven secp256k1 point with the quote's `commitHalfClaimer`, not only the DLEQ
28
+ equation and the ed25519 point. `fundOrder` also refuses a signed quote whose `commitHalfClaimer` or
29
+ `edPointClaimer` differs from the verified order terms, and `swaps.start` checks the same pair before the lock.
30
+ A proof for one half can no longer be funded beside a quote that commits to another.
31
+ - The default stagenet node is `https://xmr-stagenet.ninsei.finance`, operated by Ninsei. The old
32
+ `xmr.arrakisswap.trade` address is retired.
33
+ - `sweep.fromOrder` keeps the order's XMR amount in the recovery file. A found order carries the amount the
34
+ provider signed in atomic units; the file now records it in whole XMR, so a withdrawal restored from that file
35
+ checks the arrival against the signed figure instead of a missing or shown amount.
36
+ - `check:monero-live` starts against a real `monerod` in regtest: it refuses a non-regtest nettype, generates a
37
+ block range to an SDK-built address, runs `scanRange` over the range, and checks the daemon's `get_fee_estimate`
38
+ shape. It runs in the nightly workflow, not on every PR.
39
+ - `check:types-consumer` compiles a minimal TypeScript consumer with `tsc --noEmit` against the package's own
40
+ exports map. It imports the root, `./xmr` and `./recovery`, assigns the public interfaces and calls the declared
41
+ entry points; a declaration that parses but cannot be consumed reddens here.
42
+ - `check-packed-consumer` packs the package, installs that tarball into an empty project and imports every public
43
+ subpath, then checks the engine's relative assets. It catches a package that reads as complete but fails at the
44
+ consumer's first import.
45
+ - `check-quote-binding` mutates every signed quote field one at a time: each must change the EIP-712 digest, each
46
+ must be named when absent, and the signature bytes must stay outside the digest.
47
+ - `check-anvil-fork` now covers the refund and decline paths, a claim by a relayer with a non-zero `claimTip`, a
48
+ wrong half, `markReady` from a non-locker and a reused salt. The refund must publish the locker half; the decline
49
+ must publish none.
50
+ - `check-atomic-flow` calls `runAtomicOrder` itself with stand-in worker and funding seams: it proves the sequence
51
+ reaches funding once, carries the salt, both commitments and the view point, returns the stand-in half and joint
52
+ address, and refuses a rejected counterparty before funding.
53
+ - Pull-request CI now runs the complete registered check battery, not only the individually named steps. Storage,
54
+ scan-range, funding receipt, sweep window/order/adapter, claim-tip, claim-sender, quote-rank, provider, compose,
55
+ entries, version and settlement-profiles were registered in `check.mjs` but ran only in the release workflow.
56
+ - `check-live-contract` asks the configured RPC for the chain id, the factory and cashier code, and the
57
+ implementation code hash. It is the cheapest check against the deployed contract.
58
+ - `check-anvil-fork` forks Arbitrum Sepolia, registers a provider in the deployed registry, signs a quote locally,
59
+ runs `createOrderAndFund`, `markReady` and `claim`, and checks the event, escrow roles, revealed half and
60
+ dump/rewind repeat. Both checks run in the regular CI; Anvil is pinned to the same Foundry version as the
61
+ contract repository.
62
+ - `check:dex` runs the CoW, Kyber, UniswapX and Uniswap Relay agreement vectors (real order signatures, EIP-712
63
+ digests, calldata and refusal tables) on every pull request; they used to run only in the release workflow.
64
+ - `check-anvil-uniswap` forks Ethereum mainnet and executes the SDK's UniversalRouter calldata against the real
65
+ USDC/WETH pool, asserting native ETH reaches the recipient. It caught that Anvil's default account 2 has an
66
+ EIP-7702 delegation, so the check uses a plain EOA as the recipient.
67
+ - `check-contract-vectors` pins three half commitments, the `OrderCreated` topic and both factory selectors against
68
+ values produced by Foundry `cast`, not by the SDK's own curve and hash code. A copy that hashes the half number
69
+ instead of its secp256k1 point reddens the check.
70
+ - `check-order-proof` also exercises `orderStep`: a foreign commitment, a foreign point and a missing commitment are
71
+ refused before funding, not only by the builder in isolation.
72
+ - Pull-request CI now runs the guards that were previously release-only: sale authentication, backup confirmation,
73
+ the one-key view half, arrival consensus, the recorded arrival fixture, order proof binding, both recovery-key
74
+ paths and recovery file opening. `check-ci-scripts` also refuses a `check:*` script that no workflow names, with a
75
+ breaking run; a guard that no pull request executes no longer counts as coverage.
76
+ - `recovery.open` parses the envelope before handing it to `restoreFromFile`, which takes the object. Every valid
77
+ file used to fail as `recovery-failed { stage: "restore" }`; a non-JSON file now fails at its own `parse` stage,
78
+ and a valid file opens from both text and `{ contents }`.
79
+ - The `types` group parses each declaration file with Node's TypeScript parser, not only its text. A missing comment
80
+ opener in `index.d.ts` is now a named syntax failure; that opener is restored.
81
+ - `spendKeysFromRevealedHalf` reduces the revealed word modulo the secp256k1 order before adding it to our half. The
82
+ escrow accepts `h + n` in `claim`/`refund`; that alias is the same key on secp256k1 but not on edwards25519, so
83
+ reducing it makes such a swap withdrawable instead of refused as `revealedHalf`.
84
+ - The per-swap recovery file start path is gone. It was assembled before funding and could not carry the escrow
85
+ address, so `sweep.plan` on that file reported `revealed-half` missing. Every start now requires the one recovery
86
+ key; the key is shown and confirmed before the lock. `StartRequest.onRecoveryFile` is removed.
87
+ - `moneroFeeEstimate` reads the daemon's real units: `fee` and `fees` are atomic units per byte, and
88
+ `quantization_mask` is kept as `quantizationMask`. `feeForBytes` multiplies by the size instead of dividing by
89
+ 1024, so a 1024-byte transaction at 20000/byte is 20,480,000, not 20,000. The old `feePerKB`/`medianFeePerKB`
90
+ fields were never sent by a daemon and are gone.
91
+ - `scanRange` no longer reports a partial range as complete. A block that failed, a BUSY transaction answer, and a
92
+ response missing a requested transaction hash now appear in `missingHeights` with `complete: false` and
93
+ `incompleteReason: "blocks-unreadable"`; the per-address sums are still returned.
94
+ - A height lock is judged at the witnessing node's tip, not at the block that mined the transfer. A transfer mined at
95
+ 100 with `unlock_time` 101 is no longer `xmr-locked` at tip 120; confirmations still use the inclusion height.
96
+ `matchTransaction` takes `tipHeight` (and `scanRange` passes its top); the duplicate unlock rule in
97
+ `engine/monero/arrivalRead.js` now re-exports `engine/monero/unlock.js`.
98
+ - A split sweep reports the SUM of every signed transaction's fee, not the first one. Both wallet adapters use one
99
+ helper; `sweep.sign` subtracts the total from the remainder, and `sweep.relay` already returns every txid. One
100
+ unreadable fee makes the total unknown (`null`), never a partial figure.
101
+ - A failed settlement block-time read no longer drops every order already matched by `findSwaps`. The order is kept
102
+ with `blockTime: null` and `startHeightReason: "block-time-read"`; the other orders keep their heights.
103
+ - The retired backend no longer decides a swap. A service watch row is an observation only: `receivedXmr > 0` is
104
+ returned as `seen: false` with `next: "verify"` and never signs `markReady`, and a service `declined` status is
105
+ no longer written into the record as a settlement. The escrow's `Declined` event remains the decline source.
106
+ - Storage failures are named, not silent. A failed read, corrupt JSON, a failed write and a failed remove raise
107
+ `storage-unavailable` with `step` (`read`, `parse`, `write`, `serialize`, `remove`); only a missing value returns
108
+ the empty state. A second client with a different adapter is refused with `step: "conflict"` instead of taking
109
+ over the first client's records.
110
+ - The SDK names its own line on every body route it sends to a node: `quotes.firm`, `quotes.sale` and the funding
111
+ mark carry `sdkVersion` from the package (`src/version.mjs`). A default node no longer refuses a current SDK with
112
+ `422 sdk-version-absent`; the caller cannot claim a different line.
113
+ - Funding is no longer reported as complete from a sent transaction. `fundOrder` accepts only a status-1 receipt
114
+ with an `OrderCreated` from the factory that was called, for the predicted escrow; a reverted receipt is
115
+ `contract-reverted`, and a missing receipt, a missing event or a foreign event returns `pending` with a reason.
116
+ `swaps.start` refuses with `funding-pending` and does not save an escrow or retire a recovery number.
117
+ - The Monero wallet adapter accepts the standard networks `mainnet`, `stagenet`, `testnet` and `fakechain`, not only
118
+ the names the demo's engine registry declares. A mainnet withdrawal is no longer refused as `bad-input` before the
119
+ library is called; the address-shape check still rejects an address from another network.
120
+ - The recovery key has a salt-based v2 derivation: `ninsei-taker-v2|<chain>|<factory>|taker|<salt>`. The client draws
121
+ a fresh public salt per order; the salt is already in `OrderCreated`, so recovery costs one derivation per order
122
+ instead of an index scan. No index is reserved, and the same words on a second device or at another factory do not
123
+ repeat a half. Existing v1 orders still recover through `ninsei-taker-v1|<chain>|<index>`.
124
+ - `swaps.start` accepts the client-owned `recoverySalt` and requires it to equal `order.salt`; without it the v1 index
125
+ path is unchanged. `newRecoverySalt` and `recoverySaltOf` are exported for the quote client.
126
+ - `sweep.restore` reports `scanComplete: false` with `scanIncompleteReason: "sync-failed"`, `"behind-node"` or
127
+ `"no-heights"` when the wallet did not reach the node's height. A zero balance after a failed sync is no longer
128
+ reported as an empty wallet.
129
+ - `findSwaps` no longer stops after five empty numbers. It searches the whole allocator range by default and
130
+ returns `complete: false` with `searchedUpTo` when a caller supplies a smaller `maxIndex`; `gap` is retained
131
+ but ignored.
132
+ - `quotes.sale` authenticates the ticket instead of trusting its `signed` flag: the EIP-712 signature, expiry,
133
+ registry provider, interface allow-list, factory policy and the sale claimer's denylist are checked before the
134
+ ticket is returned.
135
+ - On the key path, `start` requires the counterparty's view half to be the one derived from the words, chain
136
+ and index. A foreign or missing half is refused before funding, so the funded address can always be rebuilt.
137
+ - A recovery key in storage is no longer treated as backed up. `backupConfirmed` is recorded only after
138
+ `onRecoveryKey` returns true; a declined or failed confirmation leaves the key unconfirmed, so the next start
139
+ asks again and cannot fund the escrow.
140
+ - The XMR arrival check requires witnesses to agree on the whole observation, not only on a block hash, and
141
+ verifies every decoded amount against its Pedersen commitment (`mask*G + amount*H == outPk`). One altered node
142
+ can no longer turn a short payment into `arrived`; an unbacked amount is `xmr-nodes-disagree`. The compact
143
+ RingCT encoding (types 4-6) derives the mask, and a recorded stagenet type-6 transfer is pinned as a fixture.
144
+ - The counterparty DLEQ proof is bound to the signed `commitHalfClaimer`: the proven secp256k1 point must match the
145
+ commitment the contract compares, and a side without a commitment is refused as input.
146
+
1
147
  ## 0.52.0 — 2026-10-26
2
148
 
3
149
  THE NEXT CONTRACT VERSION IS WIRED IN.
@@ -415,6 +561,99 @@ THE ENGINE MIRROR IS SYNCED AGAIN AFTER THE DEMO'S BUNDLE REBUILD.
415
561
 
416
562
  ## Unreleased
417
563
 
564
+ - `check:monero-live` starts against a real `monerod` in regtest: it refuses a non-regtest nettype, generates a
565
+ block range to an SDK-built address, runs `scanRange` over the range, and checks the daemon's `get_fee_estimate`
566
+ shape. It runs in the nightly workflow, not on every PR.
567
+ - `check:types-consumer` compiles a minimal TypeScript consumer with `tsc --noEmit` against the package's own
568
+ exports map. It imports the root, `./xmr` and `./recovery`, assigns the public interfaces and calls the declared
569
+ entry points; a declaration that parses but cannot be consumed reddens here.
570
+ - `check-packed-consumer` packs the package, installs that tarball into an empty project and imports every public
571
+ subpath, then checks the engine's relative assets. It catches a package that reads as complete but fails at the
572
+ consumer's first import.
573
+ - `check-quote-binding` mutates every signed quote field one at a time: each must change the EIP-712 digest, each
574
+ must be named when absent, and the signature bytes must stay outside the digest.
575
+ - `check-anvil-fork` now covers the refund and decline paths, a claim by a relayer with a non-zero `claimTip`, a
576
+ wrong half, `markReady` from a non-locker and a reused salt. The refund must publish the locker half; the decline
577
+ must publish none.
578
+ - `check-atomic-flow` calls `runAtomicOrder` itself with stand-in worker and funding seams: it proves the sequence
579
+ reaches funding once, carries the salt, both commitments and the view point, returns the stand-in half and joint
580
+ address, and refuses a rejected counterparty before funding.
581
+ - Pull-request CI now runs the complete registered check battery, not only the individually named steps. Storage,
582
+ scan-range, funding receipt, sweep window/order/adapter, claim-tip, claim-sender, quote-rank, provider, compose,
583
+ entries, version and settlement-profiles were registered in `check.mjs` but ran only in the release workflow.
584
+ - `check-live-contract` asks the configured RPC for the chain id, the factory and cashier code, and the
585
+ implementation code hash. It is the cheapest check against the deployed contract.
586
+ - `check-anvil-fork` forks Arbitrum Sepolia, registers a provider in the deployed registry, signs a quote locally,
587
+ runs `createOrderAndFund`, `markReady` and `claim`, and checks the event, escrow roles, revealed half and
588
+ dump/rewind repeat. Both checks run in the regular CI; Anvil is pinned to the same Foundry version as the
589
+ contract repository.
590
+ - `check:dex` runs the CoW, Kyber, UniswapX and Uniswap Relay agreement vectors (real order signatures, EIP-712
591
+ digests, calldata and refusal tables) on every pull request; they used to run only in the release workflow.
592
+ - `check-anvil-uniswap` forks Ethereum mainnet and executes the SDK's UniversalRouter calldata against the real
593
+ USDC/WETH pool, asserting native ETH reaches the recipient. It caught that Anvil's default account 2 has an
594
+ EIP-7702 delegation, so the check uses a plain EOA as the recipient.
595
+ - `check-contract-vectors` pins three half commitments, the `OrderCreated` topic and both factory selectors against
596
+ values produced by Foundry `cast`, not by the SDK's own curve and hash code. A copy that hashes the half number
597
+ instead of its secp256k1 point reddens the check.
598
+ - `check-order-proof` also exercises `orderStep`: a foreign commitment, a foreign point and a missing commitment are
599
+ refused before funding, not only by the builder in isolation.
600
+ - Pull-request CI now runs the guards that were previously release-only: sale authentication, backup confirmation,
601
+ the one-key view half, arrival consensus, the recorded arrival fixture, order proof binding, both recovery-key
602
+ paths and recovery file opening. `check-ci-scripts` also refuses a `check:*` script that no workflow names, with a
603
+ breaking run; a guard that no pull request executes no longer counts as coverage.
604
+ - `recovery.open` parses the envelope before handing it to `restoreFromFile`, which takes the object. Every valid
605
+ file used to fail as `recovery-failed { stage: "restore" }`; a non-JSON file now fails at its own `parse` stage,
606
+ and a valid file opens from both text and `{ contents }`.
607
+ - The `types` group parses each declaration file with Node's TypeScript parser, not only its text. A missing comment
608
+ opener in `index.d.ts` is now a named syntax failure; that opener is restored.
609
+ - `spendKeysFromRevealedHalf` reduces the revealed word modulo the secp256k1 order before adding it to our half. The
610
+ escrow accepts `h + n` in `claim`/`refund`; that alias is the same key on secp256k1 but not on edwards25519, so
611
+ reducing it makes such a swap withdrawable instead of refused as `revealedHalf`.
612
+ - The per-swap recovery file start path is gone. It was assembled before funding and could not carry the escrow
613
+ address, so `sweep.plan` on that file reported `revealed-half` missing. Every start now requires the one recovery
614
+ key; the key is shown and confirmed before the lock. `StartRequest.onRecoveryFile` is removed.
615
+ - `moneroFeeEstimate` reads the daemon's real units: `fee` and `fees` are atomic units per byte, and
616
+ `quantization_mask` is kept as `quantizationMask`. `feeForBytes` multiplies by the size instead of dividing by
617
+ 1024, so a 1024-byte transaction at 20000/byte is 20,480,000, not 20,000. The old `feePerKB`/`medianFeePerKB`
618
+ fields were never sent by a daemon and are gone.
619
+ - `scanRange` no longer reports a partial range as complete. A block that failed, a BUSY transaction answer, and a
620
+ response missing a requested transaction hash now appear in `missingHeights` with `complete: false` and
621
+ `incompleteReason: "blocks-unreadable"`; the per-address sums are still returned.
622
+ - A height lock is judged at the witnessing node's tip, not at the block that mined the transfer. A transfer mined at
623
+ 100 with `unlock_time` 101 is no longer `xmr-locked` at tip 120; confirmations still use the inclusion height.
624
+ `matchTransaction` takes `tipHeight` (and `scanRange` passes its top); the duplicate unlock rule in
625
+ `engine/monero/arrivalRead.js` now re-exports `engine/monero/unlock.js`.
626
+ - A split sweep reports the SUM of every signed transaction's fee, not the first one. Both wallet adapters use one
627
+ helper; `sweep.sign` subtracts the total from the remainder, and `sweep.relay` already returns every txid. One
628
+ unreadable fee makes the total unknown (`null`), never a partial figure.
629
+ - A failed settlement block-time read no longer drops every order already matched by `findSwaps`. The order is kept
630
+ with `blockTime: null` and `startHeightReason: "block-time-read"`; the other orders keep their heights.
631
+ - The retired backend no longer decides a swap. A service watch row is an observation only: `receivedXmr > 0` is
632
+ returned as `seen: false` with `next: "verify"` and never signs `markReady`, and a service `declined` status is
633
+ no longer written into the record as a settlement. The escrow's `Declined` event remains the decline source.
634
+ - Storage failures are named, not silent. A failed read, corrupt JSON, a failed write and a failed remove raise
635
+ `storage-unavailable` with `step` (`read`, `parse`, `write`, `serialize`, `remove`); only a missing value returns
636
+ the empty state. A second client with a different adapter is refused with `step: "conflict"` instead of taking
637
+ over the first client's records.
638
+ - The SDK names its own line on every body route it sends to a node: `quotes.firm`, `quotes.sale` and the funding
639
+ mark carry `sdkVersion` from the package (`src/version.mjs`). A default node no longer refuses a current SDK with
640
+ `422 sdk-version-absent`; the caller cannot claim a different line.
641
+ - Funding is no longer reported as complete from a sent transaction. `fundOrder` accepts only a status-1 receipt
642
+ with an `OrderCreated` from the factory that was called, for the predicted escrow; a reverted receipt is
643
+ `contract-reverted`, and a missing receipt, a missing event or a foreign event returns `pending` with a reason.
644
+ `swaps.start` refuses with `funding-pending` and does not save an escrow or retire a recovery number.
645
+ - The recovery key has a salt-based v2 derivation: `ninsei-taker-v2|<chain>|<factory>|taker|<salt>`. The client draws
646
+ a fresh public salt per order; the salt is already in `OrderCreated`, so recovery costs one derivation per order
647
+ instead of an index scan. No index is reserved, and the same words on a second device or at another factory do not
648
+ repeat a half. Existing v1 orders still recover through `ninsei-taker-v1|<chain>|<index>`.
649
+ - `swaps.start` accepts the client-owned `recoverySalt` and requires it to equal `order.salt`; without it the v1 index
650
+ path is unchanged. `newRecoverySalt` and `recoverySaltOf` are exported for the quote client.
651
+ - `sweep.restore` reports `scanComplete: false` with `scanIncompleteReason: "sync-failed"`, `"behind-node"` or
652
+ `"no-heights"` when the wallet did not reach the node's height. A zero balance after a failed sync is no longer
653
+ reported as an empty wallet.
654
+ - `findSwaps` no longer stops after five empty numbers. It searches the whole allocator range by default and
655
+ returns `complete: false` with `searchedUpTo` when a caller supplies a smaller `maxIndex`; `gap` is retained
656
+ but ignored.
418
657
  THE ENGINE MIRROR (`engine/**`) CROSSES OVER IN ENGLISH, WITHOUT THE OLD BRAND AND WITHOUT A HARDCODED DOMAIN.
419
658
  All 45 modules are copied byte-for-byte from the demo repository's `sdk/engine` (`tools/build-sdk-engine.mjs`):
420
659
  comments are concise technical English and message strings are translated one to one, `Arrakis` is renamed to
package/README.md CHANGED
@@ -26,6 +26,26 @@ machine). The wallet arrives as an EIP-1193 provider, storage as an adapter, and
26
26
  What is not ready names itself with the code `not-implemented` with the fields `surface` and `stage`: an empty
27
27
  value that looks like data is more dangerous than a refusal.
28
28
 
29
+ ## Settlement legs and VM profiles
30
+
31
+ The settlement side is a driver, not a chain id switch. A VM profile says what that side looks like. The profile is
32
+ metadata: it does not make a network runnable, and it does not carry a finality claim - finality is a chain policy,
33
+ not a VM shape.
34
+
35
+ | VM | Address form | Settlement | Ref | Driver today |
36
+ |---|---|---|---|---|
37
+ | `evm` | `hex-20` | contract | address | yes |
38
+ | `tron` | `base58check` | contract | address | profile only |
39
+ | `solana` | `base58` | program | PDA | profile only |
40
+ | `bitcoin` | `base58check-or-bech32-or-bech32m` | UTXO script | script | profile only |
41
+
42
+ The planned order is Tron, Solana, then Bitcoin. Dogecoin is not on that list and has no profile. A profile is not
43
+ a driver: `LEGS` still holds only `evm`, so Tron, Solana and Bitcoin are refused by name until their own work lands.
44
+
45
+ `chainConfig(...).settlementProfile` returns the profile for the network, or `null` for an unknown VM. The check
46
+ `tools/check.mjs --only=settlement-profiles` guards the table, the absence of Dogecoin and the absence of a finality
47
+ claim. A future driver adds its own settlement methods; the profile alone never authorizes a fund, claim or refund.
48
+
29
49
  ## The contract
30
50
 
31
51
  `index.d.ts` is the single source of truth about the API shape, and this is checked: `tools/check-sdk.mjs`
@@ -157,7 +177,13 @@ range is not aligned: it is the provider's refusal, and it arrives as a code. In
157
177
 
158
178
  ## Found orders and the paying token
159
179
 
160
- `findSwaps` returns the orders whose commitment came from the recovery key. Each found order reports:
180
+ `findSwaps` returns the orders whose commitment came from the recovery key. A salt-based order is found from the
181
+ public `salt` in its own `OrderCreated`: the binding is `ninsei-taker-v2|<chain>|<factory>|taker|<salt>`, so the same
182
+ words on another device or at another factory produce a different half and no number is reserved. The result says
183
+ `derivation: "salt-v2"` and leaves `index` null. Existing v1 orders stay on `ninsei-taker-v1|<chain>|<index>` and are
184
+ still found by the index path; a v2 result needs no range scan. For a v1 fallback, discovery does **not** stop after a
185
+ run of empty numbers: the allocator's whole range is searched by default, a smaller caller-supplied `maxIndex` returns
186
+ `complete: false` with `searchedUpTo`, and `gap` is ignored. Each found order reports:
161
187
 
162
188
  - `amount` — the escrow's **native** amount in wei;
163
189
  - `settlementToken: "native"` — what the escrow receives;
@@ -173,15 +199,62 @@ When a token is needed, the order can be joined with an off-chain quote record b
173
199
  would have to put the token into the signed quote and the clone (and therefore a new order format and factory); only
174
200
  then can `findSwaps` return it from the chain and set `payTokenReason` to something other than `not-on-chain`.
175
201
 
202
+ `swaps.start` takes the client-owned `recoverySalt` when the signed quote already carries that salt; it must equal
203
+ `order.salt`. `newRecoverySalt()` draws a fresh 32-byte value, and `takerHalvesV2()` derives the halves without
204
+ touching the index allocator.
205
+
176
206
  The found order also carries the Monero start height and the reason it is absent. `no-block-time`,
177
207
  `monero-nodes-unreachable` and `monero-network-mismatch` are different answers: a missing settlement timestamp is not
178
208
  a dead node, and a node on another network is not an absent reader. A page that starts swaps without the SDK's start
179
209
  path can call `recoveryIndexTaken` from `./recovery` before using a number; it checks the factory's `OrderCreated`
180
210
  logs from the registry's deployment block.
181
211
 
182
- The withdrawal window retries a `429`, a timeout or a `5xx` before it gives up. If the window still cannot be read,
212
+ A revealed half published as `h + n` (the alias the escrow accepts) is reduced modulo the secp256k1 order before the
213
+ sweep key check, so it rebuilds the same key and remains withdrawable.
214
+
215
+ `recovery.open` accepts the file as text or `{ contents }`: the envelope is parsed at the boundary, so a valid file
216
+ opens and a non-JSON one fails with its own `parse` stage.
217
+
218
+ The per-swap recovery file start path is gone: a start requires the one recovery key, shown and confirmed before
219
+ funding, and a file that could not name the escrow is no longer produced.
220
+
221
+ `moneroFeeEstimate` reports the daemon's per-byte `fee`/`fees` and its `quantization_mask`; `feeForBytes` multiplies
222
+ the price by the transaction size (1024 bytes at 20000/byte is 20,480,000).
223
+
224
+ A range scan says whether it was complete: `scanRange` returns `complete: false`, `missingHeights` and
225
+ `incompleteReason: "blocks-unreadable"` when a block, a BUSY transaction answer or a missing transaction hash leaves
226
+ the range partial.
227
+
228
+ A height lock is judged at the witnessing node's tip, not at the block that mined the transfer; confirmations still
229
+ use the inclusion height.
230
+
231
+ A split sweep reports the sum of every signed transaction's fee, not only the first; one unreadable fee makes the
232
+ total unknown.
233
+
234
+ A failed settlement block-time read keeps the matched order: `findSwaps` returns `blockTime: null` with
235
+ `startHeightReason: "block-time-read"` instead of throwing away every row.
236
+
237
+ The retired backend no longer decides a swap: its watch row may observe money, but it cannot declare readiness or
238
+ a decline; the client's two-node check and the escrow's own `Declined` event do.
239
+
240
+ Storage is a refusal, not a silent empty state: a failed read, corrupt JSON, a failed write or remove is
241
+ `storage-unavailable` with a step, and a second client with another adapter is refused rather than taking over the
242
+ first client's records.
243
+
244
+ Every body route the SDK sends carries `sdkVersion` from the package (`quotes.firm`, `quotes.sale` and the funding
245
+ mark). The caller cannot override it with a different line.
246
+
247
+ Funding is reported from the receipt, not from the send. A status-0 receipt is `contract-reverted`; a missing
248
+ receipt, a receipt without `OrderCreated`, or an event from another factory is `funding-pending` with the transaction
249
+ hash, and `swaps.start` does not save an escrow or retire a recovery number in that state.
250
+
251
+ The wallet adapter accepts the standard Monero network names (`mainnet`, `stagenet`, `testnet`, `fakechain`) and
252
+ still checks the address shape, so a mainnet withdrawal is not refused because the demo registry lists only
253
+ stagenet. The withdrawal window retries a `429`, a timeout or a `5xx` before it gives up. If the window still cannot be read,
183
254
  `restoreWallet` falls back to the full scan and reports `window.fallback: true` with `fallbackWhy: "window-read"` and
184
- the node's status; a withdrawal is not refused because the node was busy.
255
+ the node's status; a withdrawal is not refused because the node was busy. When the wallet cannot reach the node's
256
+ height, the result says `scanComplete: false` with `scanIncompleteReason: "sync-failed"`, `"behind-node"` or
257
+ `"no-heights"`; a zero balance in that state is not an empty wallet.
185
258
 
186
259
  ## Extending to other networks (Tron, Solana and beyond)
187
260
 
@@ -13,6 +13,20 @@ import {
13
13
 
14
14
  const builder = createOrderBuilder({ ed25519, secp256k1, keccak256, sha3_512, randomBytes });
15
15
 
16
+ // THE COMMITMENT THE CONTRACT WILL COMPARE, DERIVED FROM THE PROVEN POINT. The worker's vendored builder can check
17
+ // the DLEQ equation, but its verifySide did not compare the proven secp256k1 point with the quote's commitment.
18
+ // engine/atomic/order.js carries that comparison; this worker runs the prebuilt bundle, so the same comparison is
19
+ // made here from the bundle's own secp256k1 and keccak256. A proof that is valid for another point must not build a
20
+ // side that funding then signs.
21
+ const commitmentOfPoint = (value) => {
22
+ try {
23
+ const point = secp256k1.Point.fromHex(String(value || "").replace(/^0x/i, ""));
24
+ const xy = point.toBytes(false).slice(1);
25
+ return "0x" + Array.from(keccak256(xy), (b) => b.toString(16).padStart(2, "0")).join("");
26
+ } catch { return null; }
27
+ };
28
+ const low160 = (value) => String(value || "").replace(/^0x/i, "").toLowerCase().slice(-40);
29
+
16
30
  // Ключи шифрования построенных сторон: приватный ключ по своему публичному. Наружу приватные не уходят -
17
31
  // вскрытие посылки происходит здесь.
18
32
  //
@@ -111,14 +125,27 @@ self.onmessage = async (event) => {
111
125
  });
112
126
  } else if (type === "verify") {
113
127
  say("verify", "проверяю доказательство контрагента");
114
- const side = { proof: counterparty.proof, pub: { ed: counterparty.edPub } };
128
+ const side = { proof: counterparty.proof, pub: { ed: counterparty.edPub }, commitHalfClaimer: counterparty.commitHalfClaimer || null };
115
129
  // Вскрываем тем ключом, на который посылка была зашифрована в этом контексте.
116
130
  const recipient = sealedToPub.get(String(orderContext));
117
131
  const openWith = recipient ? encPrivByPub.get(recipient) : null;
118
132
  if (sealedBytes && !openWith) {
119
133
  throw new Error("нет ключа, которым вскрывать посылку: не было шифрования в этом контексте ордера");
120
134
  }
121
- const verdict = builder.verifySide(side, orderContext, sealedBytes || null, openWith);
135
+ let verdict = builder.verifySide(side, orderContext, sealedBytes || null, openWith);
136
+ // THE PROOF IS ABOUT THE COMMITMENT IN THE QUOTE, not only about two points that stand for one scalar. The
137
+ // contract pays against commitHalfClaimer; without this comparison a counterparty proves one point and funds
138
+ // another, and the half revealed at claim time does not open the joint address.
139
+ if (verdict && verdict.ok === true) {
140
+ if (!counterparty.commitHalfClaimer) {
141
+ verdict = { ok: false, reason: "the counterparty side carries no commitHalfClaimer" };
142
+ } else {
143
+ const proven = commitmentOfPoint(counterparty.proof && counterparty.proof.XA);
144
+ if (!proven || low160(proven) !== low160(counterparty.commitHalfClaimer)) {
145
+ verdict = { ok: false, reason: "the proven secp256k1 point does not match commitHalfClaimer: " + (proven || "unparsed") + " vs " + counterparty.commitHalfClaimer };
146
+ }
147
+ }
148
+ }
122
149
  self.postMessage({ id, kind: "done", result: verdict });
123
150
  } else {
124
151
  self.postMessage({ id, kind: "error", message: "неизвестный запрос воркеру: " + String(type) });
@@ -60,6 +60,30 @@ export function createOrderBuilder({ ed25519, secp256k1, keccak256, sha3_512, ra
60
60
  if (side.pub && side.pub.ed && String(side.proof.XB).toLowerCase() !== String(side.pub.ed).toLowerCase()) {
61
61
  return { ok: false, reason: "the proven ed25519 point does not match the declared one: " + side.proof.XB + " vs " + side.pub.ed };
62
62
  }
63
+ // THE COMMITMENT IS PART OF THE PROOF'S MEANING. The contract does not compare halves to a hash of a number:
64
+ // it compares `half*G` (through ecrecover) with `commitHalfClaimer`. A proof confirms only that XA and XB stand
65
+ // for one scalar; without this comparison a counterparty could prove XA, put another point in the quote, and
66
+ // the contract would reject the half at claim time - after the other side has paid.
67
+ //
68
+ // `proof.XA` is the compressed secp256k1 point of the half. The commitment is keccak256 of the UNCOMPRESSED
69
+ // 64-byte coordinates, and the contract compares only its low 160 bits (`uint160`), so the comparison is made
70
+ // on the same 20 bytes.
71
+ if (side.commitHalfClaimer) {
72
+ let provenCommit = null;
73
+ try {
74
+ const point = secp256k1.Point.fromHex(String(side.proof.XA).replace(/^0x/, ""));
75
+ const xy = point.toBytes(false).slice(1);
76
+ provenCommit = "0x" + Array.from(keccak256(xy), (b) => b.toString(16).padStart(2, "0")).join("");
77
+ } catch { provenCommit = null; }
78
+ const low160 = (v) => String(v || "").replace(/^0x/, "").toLowerCase().slice(-40);
79
+ if (!provenCommit || low160(provenCommit) !== low160(side.commitHalfClaimer)) {
80
+ return { ok: false, reason: "the proven secp256k1 point does not match commitHalfClaimer: " + (provenCommit || "unparsed") + " vs " + side.commitHalfClaimer };
81
+ }
82
+ } else {
83
+ // A REAL COUNTERPARTY MUST CARRY THE COMMITMENT. Accepting a proof without it leaves the link between the
84
+ // proven half and the contract unchecked; the stand-in path supplies its own commitment in order.mjs.
85
+ return { ok: false, reason: "the counterparty side carries no commitHalfClaimer" };
86
+ }
63
87
  if (sealedPayloadBytes && ownEncPriv) {
64
88
  const opened = enc.open(enc.unpackSealed(sealedPayloadBytes), ownEncPriv, side.pub.ed);
65
89
  if (!opened.ok) return { ok: false, reason: "the counterparty envelope does not confirm the half: " + opened.reason };
@@ -46,11 +46,40 @@ const webStorage = (name) => {
46
46
  };
47
47
 
48
48
  let adapter = webStorage("localStorage") || memoryStorage();
49
+ // A SECOND CLIENT WITH ANOTHER STORE USED TO TAKE OVER THE FIRST ONE'S RECORDS: the adapter was module-wide, so
50
+ // A listed B's swaps and A's forget deleted them. The SDK binds one explicit store to the process; a different
51
+ // one is refused instead of silently switching under a live client. `setStorage` remains the test/setup reset.
52
+ let bound = null;
49
53
 
50
- /** Swap the storage. The shape is checked, not the origin: localStorage, memory and the SDK all qualify. */
51
- export function setStorage(next) {
54
+ const checkAdapter = (next) => {
52
55
  const ok = next && typeof next.get === "function" && typeof next.set === "function" && typeof next.remove === "function";
53
56
  if (!ok) throw new Error("storage must support get/set/remove");
57
+ };
58
+
59
+ const storageFailure = (step, cause) => {
60
+ const error = new Error("storage-unavailable");
61
+ error.code = "storage-unavailable";
62
+ error.params = { step };
63
+ if (cause) error.cause = cause;
64
+ return error;
65
+ };
66
+
67
+ /** Swap the storage for setup and tests. It also releases the exclusive binding, so a later client may bind. */
68
+ export function setStorage(next) {
69
+ checkAdapter(next);
70
+ adapter = next;
71
+ bound = null;
72
+ return adapter;
73
+ }
74
+
75
+ /**
76
+ * Bind the store of a client instance. A second, different store is a refusal: records and recovery keys are
77
+ * addressed through one adapter, and switching it under a live client loses the first client's records.
78
+ */
79
+ export function bindStorage(next) {
80
+ checkAdapter(next);
81
+ if (bound && bound !== next) throw storageFailure("conflict");
82
+ bound = next;
54
83
  adapter = next;
55
84
  return adapter;
56
85
  }
@@ -59,14 +88,23 @@ export function setStorage(next) {
59
88
  export const storage = () => adapter;
60
89
 
61
90
  export function loadState() {
91
+ let raw;
92
+ try {
93
+ raw = adapter.get(APP.storageKey);
94
+ } catch (error) {
95
+ throw storageFailure("read", error);
96
+ }
97
+ // NOTHING STORED IS NOT AN UNREADABLE STORE. Only a missing value returns the empty state; a value that does
98
+ // not parse is a refusal, so the next write cannot silently save over a corrupt record.
99
+ if (raw === null || raw === undefined || raw === "") return structuredClone(EMPTY);
100
+ let parsed;
62
101
  try {
63
- const raw = adapter.get(APP.storageKey);
64
- if (!raw) return structuredClone(EMPTY);
65
- const parsed = JSON.parse(raw);
66
- return { ...structuredClone(EMPTY), ...parsed, settings: { ...EMPTY.settings, ...(parsed.settings || {}) } };
67
- } catch {
68
- return structuredClone(EMPTY);
102
+ parsed = JSON.parse(raw);
103
+ } catch (error) {
104
+ throw storageFailure("parse", error);
69
105
  }
106
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw storageFailure("parse");
107
+ return { ...structuredClone(EMPTY), ...parsed, settings: { ...EMPTY.settings, ...(parsed.settings || {}) } };
70
108
  }
71
109
 
72
110
  // BigInt IS NOT SERIALISED IN JSON - and the key halves are BigInt. JSON.stringify FAILS on them
@@ -75,10 +113,16 @@ export function loadState() {
75
113
  // We turn BigInt into a hex string: the same meaning, but serialisable, and the secret is not lost.
76
114
  const bigintSafe = (key, value) => (typeof value === "bigint" ? "0x" + value.toString(16) : value);
77
115
  export function saveState(state) {
116
+ let text;
78
117
  try {
79
- adapter.set(APP.storageKey, JSON.stringify(state, bigintSafe));
80
- } catch (e) {
81
- console.warn("state save failed", e);
118
+ text = JSON.stringify(state, bigintSafe);
119
+ } catch (error) {
120
+ throw storageFailure("serialize", error);
121
+ }
122
+ try {
123
+ adapter.set(APP.storageKey, text);
124
+ } catch (error) {
125
+ throw storageFailure("write", error);
82
126
  }
83
127
  return state;
84
128
  }
@@ -91,7 +135,11 @@ export function update(mutator) {
91
135
  }
92
136
 
93
137
  export function reset() {
94
- adapter.remove(APP.storageKey);
138
+ try {
139
+ adapter.remove(APP.storageKey);
140
+ } catch (error) {
141
+ throw storageFailure("remove", error);
142
+ }
95
143
  }
96
144
 
97
145
  export function exportState() {