@ninsei-labs/sdk 0.52.0 → 0.54.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,176 @@
1
+ ## 0.54.0 — 2026-10-29
2
+
3
+ - `findSwaps` no longer walks the allocator's 100,000 v1 indices in one synchronous call. One call scans at most
4
+ `RECOVERY_SCAN_DEFAULT` (1000) indices, yields to the event loop between chunks, and returns `nextIndex` with
5
+ `complete: false` and `incompleteReason: "max-index"` when it stopped. A caller continues from `nextIndex`; a search
6
+ that matched every order in the logs is complete without scanning the rest. `maxIndex` is the number of indices for
7
+ this call.
8
+ - `findSwaps` accepts an AbortSignal-like `signal` and an `onProgress` callback. An aborted scan returns
9
+ `incompleteReason: "aborted"` with the same `nextIndex`; progress is reported after every chunk and after the final
10
+ index. The SDK creates no worker: a caller can run the same call in one.
11
+ - The relay firm client has its signed-features check and request builder: a `NodeFeatures` digest with the
12
+ `FirmWindow` array, signature recovery, request binding, a normalised client half, and one HPKE envelope per
13
+ request. Ask carries one view scalar; bid carries two. Wire revision 13 removed the `capabilities` array: the
14
+ features now always carry a nonzero HPKE key and at least one firm window.
15
+ - The relay firm client's HTTP hop is `requestFirmQuote`: it posts the exact payload once, keeps a relay refusal
16
+ code (`compliance`, `no_liquidity`, `size`, `paused`, `unavailable`, `bad_request`, `price_changed`), maps a
17
+ transport failure to `server-unavailable`, and verifies the outer frame and the inner `OrderQuote` before
18
+ returning. It also checks the relay/forwarded/answer relay-id equality.
19
+ - `quotes.firmRelay` is the recovery-key adapter: it derives the client half and both view points from one
20
+ key, fills the provider's role for ask or bid, verifies the node half's DLEQ proof and commitment, sends one
21
+ relay request, and checks the registry provider and factory against the answer before returning the signed
22
+ `orderQuote`, the one-time joint XMR address, a `counterparty` object ready for `swaps.start`, and the maker
23
+ view half. `FirmOffer` now matches the direct `firm` call (`providerId`, `order`, optional `viewHalfClaimer`).
24
+ - `swaps.sellWatch` is the client-side sell wait: it reads the escrow itself and returns `ready`,
25
+ `claimed` or `refunded` (or `sell-timeout`), with no backend watcher and no signature.
26
+ - `swaps.startSell` records a sell from a verified relay offer: the one-time address, escrow, our half and the
27
+ order expectations survive a reload; the same offer resumes the same record. `swaps.sellClaim` claims from
28
+ that record, from the claimer's own wallet.
29
+
30
+ ## 0.53.0 — 2026-10-28
31
+
32
+ - A failed `rescanBlockchain` is no longer discarded: `sweep.restore` reports `scanComplete: false` with
33
+ `scanIncompleteReason: "rescan-failed"`, even when the wallet and node heights are equal. A wallet already at the
34
+ tip cannot turn a failed rescan into a successful no-op sync.
35
+ - A `funding-pending` start keeps the sent transaction hash and the predicted escrow on the swap record. A retry of
36
+ the same quote reads that receipt and continues to `xmr-incoming` when it is funded, or returns
37
+ `funding-pending` again; it never builds a second side or sends a second deposit.
38
+ - `scanRange` treats a block without its decoded `json`, a BUSY block, a transaction body count below the wrapper
39
+ count and an extra transaction as unreadable: the block is named in `missingHeights`, not counted as empty or as
40
+ money. Transaction results are mapped back by requested hash, so a partial answer cannot pass as complete.
41
+ - The recovery-key store distinguishes "nothing stored" from "could not read": a throwing read is
42
+ `storage-unavailable { step: "read" }`, corrupt or unknown JSON is `storage-unavailable { step: "parse" }`, and a
43
+ failed write is `storage-unavailable { step: "write" }`. A corrupt record is no longer silently replaced by the
44
+ next start.
45
+ - A new `swaps.start` uses a per-order salt by default: the order's own salt is the v2 recovery salt, and a missing
46
+ salt is drawn before the order context is built. A v1 recovery index is used only when the caller names one
47
+ explicitly, for restoring an old order.
48
+ - The counterparty's view material is normalised once for the guard and the builder: conflicting `viewHalf` /
49
+ `viewHalfClaimer` or `viewPoint` / `viewPub` aliases are refused, the point is derived from the half when only the
50
+ half is present, and a half whose derived point differs from the declared point is refused before funding.
51
+ - `quotes.sale` reads the `{ request: {...} }` envelope as the request, so a claimer named inside it is bound to the
52
+ signed quote. The requested XMR is compared with the signed `xmrAmount`, and a returned order is compared field by
53
+ field with the signed terms instead of only with the derived `context`.
54
+ - The order worker now compares the proven secp256k1 point with the quote's `commitHalfClaimer`, not only the DLEQ
55
+ equation and the ed25519 point. `fundOrder` also refuses a signed quote whose `commitHalfClaimer` or
56
+ `edPointClaimer` differs from the verified order terms, and `swaps.start` checks the same pair before the lock.
57
+ A proof for one half can no longer be funded beside a quote that commits to another.
58
+ - The default stagenet node is `https://xmr-stagenet.ninsei.finance`, operated by Ninsei. The old
59
+ `xmr.arrakisswap.trade` address is retired.
60
+ - `sweep.fromOrder` keeps the order's XMR amount in the recovery file. A found order carries the amount the
61
+ provider signed in atomic units; the file now records it in whole XMR, so a withdrawal restored from that file
62
+ checks the arrival against the signed figure instead of a missing or shown amount.
63
+ - `check:monero-live` starts against a real `monerod` in regtest: it refuses a non-regtest nettype, generates a
64
+ block range to an SDK-built address, runs `scanRange` over the range, and checks the daemon's `get_fee_estimate`
65
+ shape. It runs in the nightly workflow, not on every PR.
66
+ - `check:types-consumer` compiles a minimal TypeScript consumer with `tsc --noEmit` against the package's own
67
+ exports map. It imports the root, `./xmr` and `./recovery`, assigns the public interfaces and calls the declared
68
+ entry points; a declaration that parses but cannot be consumed reddens here.
69
+ - `check-packed-consumer` packs the package, installs that tarball into an empty project and imports every public
70
+ subpath, then checks the engine's relative assets. It catches a package that reads as complete but fails at the
71
+ consumer's first import.
72
+ - `check-quote-binding` mutates every signed quote field one at a time: each must change the EIP-712 digest, each
73
+ must be named when absent, and the signature bytes must stay outside the digest.
74
+ - `check-anvil-fork` now covers the refund and decline paths, a claim by a relayer with a non-zero `claimTip`, a
75
+ wrong half, `markReady` from a non-locker and a reused salt. The refund must publish the locker half; the decline
76
+ must publish none.
77
+ - `check-atomic-flow` calls `runAtomicOrder` itself with stand-in worker and funding seams: it proves the sequence
78
+ reaches funding once, carries the salt, both commitments and the view point, returns the stand-in half and joint
79
+ address, and refuses a rejected counterparty before funding.
80
+ - Pull-request CI now runs the complete registered check battery, not only the individually named steps. Storage,
81
+ scan-range, funding receipt, sweep window/order/adapter, claim-tip, claim-sender, quote-rank, provider, compose,
82
+ entries, version and settlement-profiles were registered in `check.mjs` but ran only in the release workflow.
83
+ - `check-live-contract` asks the configured RPC for the chain id, the factory and cashier code, and the
84
+ implementation code hash. It is the cheapest check against the deployed contract.
85
+ - `check-anvil-fork` forks Arbitrum Sepolia, registers a provider in the deployed registry, signs a quote locally,
86
+ runs `createOrderAndFund`, `markReady` and `claim`, and checks the event, escrow roles, revealed half and
87
+ dump/rewind repeat. Both checks run in the regular CI; Anvil is pinned to the same Foundry version as the
88
+ contract repository.
89
+ - `check:dex` runs the CoW, Kyber, UniswapX and Uniswap Relay agreement vectors (real order signatures, EIP-712
90
+ digests, calldata and refusal tables) on every pull request; they used to run only in the release workflow.
91
+ - `check-anvil-uniswap` forks Ethereum mainnet and executes the SDK's UniversalRouter calldata against the real
92
+ USDC/WETH pool, asserting native ETH reaches the recipient. It caught that Anvil's default account 2 has an
93
+ EIP-7702 delegation, so the check uses a plain EOA as the recipient.
94
+ - `check-contract-vectors` pins three half commitments, the `OrderCreated` topic and both factory selectors against
95
+ values produced by Foundry `cast`, not by the SDK's own curve and hash code. A copy that hashes the half number
96
+ instead of its secp256k1 point reddens the check.
97
+ - `check-order-proof` also exercises `orderStep`: a foreign commitment, a foreign point and a missing commitment are
98
+ refused before funding, not only by the builder in isolation.
99
+ - Pull-request CI now runs the guards that were previously release-only: sale authentication, backup confirmation,
100
+ the one-key view half, arrival consensus, the recorded arrival fixture, order proof binding, both recovery-key
101
+ paths and recovery file opening. `check-ci-scripts` also refuses a `check:*` script that no workflow names, with a
102
+ breaking run; a guard that no pull request executes no longer counts as coverage.
103
+ - `recovery.open` parses the envelope before handing it to `restoreFromFile`, which takes the object. Every valid
104
+ file used to fail as `recovery-failed { stage: "restore" }`; a non-JSON file now fails at its own `parse` stage,
105
+ and a valid file opens from both text and `{ contents }`.
106
+ - The `types` group parses each declaration file with Node's TypeScript parser, not only its text. A missing comment
107
+ opener in `index.d.ts` is now a named syntax failure; that opener is restored.
108
+ - `spendKeysFromRevealedHalf` reduces the revealed word modulo the secp256k1 order before adding it to our half. The
109
+ escrow accepts `h + n` in `claim`/`refund`; that alias is the same key on secp256k1 but not on edwards25519, so
110
+ reducing it makes such a swap withdrawable instead of refused as `revealedHalf`.
111
+ - The per-swap recovery file start path is gone. It was assembled before funding and could not carry the escrow
112
+ address, so `sweep.plan` on that file reported `revealed-half` missing. Every start now requires the one recovery
113
+ key; the key is shown and confirmed before the lock. `StartRequest.onRecoveryFile` is removed.
114
+ - `moneroFeeEstimate` reads the daemon's real units: `fee` and `fees` are atomic units per byte, and
115
+ `quantization_mask` is kept as `quantizationMask`. `feeForBytes` multiplies by the size instead of dividing by
116
+ 1024, so a 1024-byte transaction at 20000/byte is 20,480,000, not 20,000. The old `feePerKB`/`medianFeePerKB`
117
+ fields were never sent by a daemon and are gone.
118
+ - `scanRange` no longer reports a partial range as complete. A block that failed, a BUSY transaction answer, and a
119
+ response missing a requested transaction hash now appear in `missingHeights` with `complete: false` and
120
+ `incompleteReason: "blocks-unreadable"`; the per-address sums are still returned.
121
+ - A height lock is judged at the witnessing node's tip, not at the block that mined the transfer. A transfer mined at
122
+ 100 with `unlock_time` 101 is no longer `xmr-locked` at tip 120; confirmations still use the inclusion height.
123
+ `matchTransaction` takes `tipHeight` (and `scanRange` passes its top); the duplicate unlock rule in
124
+ `engine/monero/arrivalRead.js` now re-exports `engine/monero/unlock.js`.
125
+ - A split sweep reports the SUM of every signed transaction's fee, not the first one. Both wallet adapters use one
126
+ helper; `sweep.sign` subtracts the total from the remainder, and `sweep.relay` already returns every txid. One
127
+ unreadable fee makes the total unknown (`null`), never a partial figure.
128
+ - A failed settlement block-time read no longer drops every order already matched by `findSwaps`. The order is kept
129
+ with `blockTime: null` and `startHeightReason: "block-time-read"`; the other orders keep their heights.
130
+ - The retired backend no longer decides a swap. A service watch row is an observation only: `receivedXmr > 0` is
131
+ returned as `seen: false` with `next: "verify"` and never signs `markReady`, and a service `declined` status is
132
+ no longer written into the record as a settlement. The escrow's `Declined` event remains the decline source.
133
+ - Storage failures are named, not silent. A failed read, corrupt JSON, a failed write and a failed remove raise
134
+ `storage-unavailable` with `step` (`read`, `parse`, `write`, `serialize`, `remove`); only a missing value returns
135
+ the empty state. A second client with a different adapter is refused with `step: "conflict"` instead of taking
136
+ over the first client's records.
137
+ - The SDK names its own line on every body route it sends to a node: `quotes.firm`, `quotes.sale` and the funding
138
+ mark carry `sdkVersion` from the package (`src/version.mjs`). A default node no longer refuses a current SDK with
139
+ `422 sdk-version-absent`; the caller cannot claim a different line.
140
+ - Funding is no longer reported as complete from a sent transaction. `fundOrder` accepts only a status-1 receipt
141
+ with an `OrderCreated` from the factory that was called, for the predicted escrow; a reverted receipt is
142
+ `contract-reverted`, and a missing receipt, a missing event or a foreign event returns `pending` with a reason.
143
+ `swaps.start` refuses with `funding-pending` and does not save an escrow or retire a recovery number.
144
+ - The Monero wallet adapter accepts the standard networks `mainnet`, `stagenet`, `testnet` and `fakechain`, not only
145
+ the names the demo's engine registry declares. A mainnet withdrawal is no longer refused as `bad-input` before the
146
+ library is called; the address-shape check still rejects an address from another network.
147
+ - The recovery key has a salt-based v2 derivation: `ninsei-taker-v2|<chain>|<factory>|taker|<salt>`. The client draws
148
+ a fresh public salt per order; the salt is already in `OrderCreated`, so recovery costs one derivation per order
149
+ instead of an index scan. No index is reserved, and the same words on a second device or at another factory do not
150
+ repeat a half. Existing v1 orders still recover through `ninsei-taker-v1|<chain>|<index>`.
151
+ - `swaps.start` accepts the client-owned `recoverySalt` and requires it to equal `order.salt`; without it the v1 index
152
+ path is unchanged. `newRecoverySalt` and `recoverySaltOf` are exported for the quote client.
153
+ - `sweep.restore` reports `scanComplete: false` with `scanIncompleteReason: "sync-failed"`, `"behind-node"` or
154
+ `"no-heights"` when the wallet did not reach the node's height. A zero balance after a failed sync is no longer
155
+ reported as an empty wallet.
156
+ - `findSwaps` no longer stops after five empty numbers. It searches the whole allocator range by default and
157
+ returns `complete: false` with `searchedUpTo` when a caller supplies a smaller `maxIndex`; `gap` is retained
158
+ but ignored.
159
+ - `quotes.sale` authenticates the ticket instead of trusting its `signed` flag: the EIP-712 signature, expiry,
160
+ registry provider, interface allow-list, factory policy and the sale claimer's denylist are checked before the
161
+ ticket is returned.
162
+ - On the key path, `start` requires the counterparty's view half to be the one derived from the words, chain
163
+ and index. A foreign or missing half is refused before funding, so the funded address can always be rebuilt.
164
+ - A recovery key in storage is no longer treated as backed up. `backupConfirmed` is recorded only after
165
+ `onRecoveryKey` returns true; a declined or failed confirmation leaves the key unconfirmed, so the next start
166
+ asks again and cannot fund the escrow.
167
+ - The XMR arrival check requires witnesses to agree on the whole observation, not only on a block hash, and
168
+ verifies every decoded amount against its Pedersen commitment (`mask*G + amount*H == outPk`). One altered node
169
+ can no longer turn a short payment into `arrived`; an unbacked amount is `xmr-nodes-disagree`. The compact
170
+ RingCT encoding (types 4-6) derives the mask, and a recorded stagenet type-6 transfer is pinned as a fixture.
171
+ - The counterparty DLEQ proof is bound to the signed `commitHalfClaimer`: the proven secp256k1 point must match the
172
+ commitment the contract compares, and a side without a commitment is refused as input.
173
+
1
174
  ## 0.52.0 — 2026-10-26
2
175
 
3
176
  THE NEXT CONTRACT VERSION IS WIRED IN.
@@ -415,6 +588,99 @@ THE ENGINE MIRROR IS SYNCED AGAIN AFTER THE DEMO'S BUNDLE REBUILD.
415
588
 
416
589
  ## Unreleased
417
590
 
591
+ - `check:monero-live` starts against a real `monerod` in regtest: it refuses a non-regtest nettype, generates a
592
+ block range to an SDK-built address, runs `scanRange` over the range, and checks the daemon's `get_fee_estimate`
593
+ shape. It runs in the nightly workflow, not on every PR.
594
+ - `check:types-consumer` compiles a minimal TypeScript consumer with `tsc --noEmit` against the package's own
595
+ exports map. It imports the root, `./xmr` and `./recovery`, assigns the public interfaces and calls the declared
596
+ entry points; a declaration that parses but cannot be consumed reddens here.
597
+ - `check-packed-consumer` packs the package, installs that tarball into an empty project and imports every public
598
+ subpath, then checks the engine's relative assets. It catches a package that reads as complete but fails at the
599
+ consumer's first import.
600
+ - `check-quote-binding` mutates every signed quote field one at a time: each must change the EIP-712 digest, each
601
+ must be named when absent, and the signature bytes must stay outside the digest.
602
+ - `check-anvil-fork` now covers the refund and decline paths, a claim by a relayer with a non-zero `claimTip`, a
603
+ wrong half, `markReady` from a non-locker and a reused salt. The refund must publish the locker half; the decline
604
+ must publish none.
605
+ - `check-atomic-flow` calls `runAtomicOrder` itself with stand-in worker and funding seams: it proves the sequence
606
+ reaches funding once, carries the salt, both commitments and the view point, returns the stand-in half and joint
607
+ address, and refuses a rejected counterparty before funding.
608
+ - Pull-request CI now runs the complete registered check battery, not only the individually named steps. Storage,
609
+ scan-range, funding receipt, sweep window/order/adapter, claim-tip, claim-sender, quote-rank, provider, compose,
610
+ entries, version and settlement-profiles were registered in `check.mjs` but ran only in the release workflow.
611
+ - `check-live-contract` asks the configured RPC for the chain id, the factory and cashier code, and the
612
+ implementation code hash. It is the cheapest check against the deployed contract.
613
+ - `check-anvil-fork` forks Arbitrum Sepolia, registers a provider in the deployed registry, signs a quote locally,
614
+ runs `createOrderAndFund`, `markReady` and `claim`, and checks the event, escrow roles, revealed half and
615
+ dump/rewind repeat. Both checks run in the regular CI; Anvil is pinned to the same Foundry version as the
616
+ contract repository.
617
+ - `check:dex` runs the CoW, Kyber, UniswapX and Uniswap Relay agreement vectors (real order signatures, EIP-712
618
+ digests, calldata and refusal tables) on every pull request; they used to run only in the release workflow.
619
+ - `check-anvil-uniswap` forks Ethereum mainnet and executes the SDK's UniversalRouter calldata against the real
620
+ USDC/WETH pool, asserting native ETH reaches the recipient. It caught that Anvil's default account 2 has an
621
+ EIP-7702 delegation, so the check uses a plain EOA as the recipient.
622
+ - `check-contract-vectors` pins three half commitments, the `OrderCreated` topic and both factory selectors against
623
+ values produced by Foundry `cast`, not by the SDK's own curve and hash code. A copy that hashes the half number
624
+ instead of its secp256k1 point reddens the check.
625
+ - `check-order-proof` also exercises `orderStep`: a foreign commitment, a foreign point and a missing commitment are
626
+ refused before funding, not only by the builder in isolation.
627
+ - Pull-request CI now runs the guards that were previously release-only: sale authentication, backup confirmation,
628
+ the one-key view half, arrival consensus, the recorded arrival fixture, order proof binding, both recovery-key
629
+ paths and recovery file opening. `check-ci-scripts` also refuses a `check:*` script that no workflow names, with a
630
+ breaking run; a guard that no pull request executes no longer counts as coverage.
631
+ - `recovery.open` parses the envelope before handing it to `restoreFromFile`, which takes the object. Every valid
632
+ file used to fail as `recovery-failed { stage: "restore" }`; a non-JSON file now fails at its own `parse` stage,
633
+ and a valid file opens from both text and `{ contents }`.
634
+ - The `types` group parses each declaration file with Node's TypeScript parser, not only its text. A missing comment
635
+ opener in `index.d.ts` is now a named syntax failure; that opener is restored.
636
+ - `spendKeysFromRevealedHalf` reduces the revealed word modulo the secp256k1 order before adding it to our half. The
637
+ escrow accepts `h + n` in `claim`/`refund`; that alias is the same key on secp256k1 but not on edwards25519, so
638
+ reducing it makes such a swap withdrawable instead of refused as `revealedHalf`.
639
+ - The per-swap recovery file start path is gone. It was assembled before funding and could not carry the escrow
640
+ address, so `sweep.plan` on that file reported `revealed-half` missing. Every start now requires the one recovery
641
+ key; the key is shown and confirmed before the lock. `StartRequest.onRecoveryFile` is removed.
642
+ - `moneroFeeEstimate` reads the daemon's real units: `fee` and `fees` are atomic units per byte, and
643
+ `quantization_mask` is kept as `quantizationMask`. `feeForBytes` multiplies by the size instead of dividing by
644
+ 1024, so a 1024-byte transaction at 20000/byte is 20,480,000, not 20,000. The old `feePerKB`/`medianFeePerKB`
645
+ fields were never sent by a daemon and are gone.
646
+ - `scanRange` no longer reports a partial range as complete. A block that failed, a BUSY transaction answer, and a
647
+ response missing a requested transaction hash now appear in `missingHeights` with `complete: false` and
648
+ `incompleteReason: "blocks-unreadable"`; the per-address sums are still returned.
649
+ - A height lock is judged at the witnessing node's tip, not at the block that mined the transfer. A transfer mined at
650
+ 100 with `unlock_time` 101 is no longer `xmr-locked` at tip 120; confirmations still use the inclusion height.
651
+ `matchTransaction` takes `tipHeight` (and `scanRange` passes its top); the duplicate unlock rule in
652
+ `engine/monero/arrivalRead.js` now re-exports `engine/monero/unlock.js`.
653
+ - A split sweep reports the SUM of every signed transaction's fee, not the first one. Both wallet adapters use one
654
+ helper; `sweep.sign` subtracts the total from the remainder, and `sweep.relay` already returns every txid. One
655
+ unreadable fee makes the total unknown (`null`), never a partial figure.
656
+ - A failed settlement block-time read no longer drops every order already matched by `findSwaps`. The order is kept
657
+ with `blockTime: null` and `startHeightReason: "block-time-read"`; the other orders keep their heights.
658
+ - The retired backend no longer decides a swap. A service watch row is an observation only: `receivedXmr > 0` is
659
+ returned as `seen: false` with `next: "verify"` and never signs `markReady`, and a service `declined` status is
660
+ no longer written into the record as a settlement. The escrow's `Declined` event remains the decline source.
661
+ - Storage failures are named, not silent. A failed read, corrupt JSON, a failed write and a failed remove raise
662
+ `storage-unavailable` with `step` (`read`, `parse`, `write`, `serialize`, `remove`); only a missing value returns
663
+ the empty state. A second client with a different adapter is refused with `step: "conflict"` instead of taking
664
+ over the first client's records.
665
+ - The SDK names its own line on every body route it sends to a node: `quotes.firm`, `quotes.sale` and the funding
666
+ mark carry `sdkVersion` from the package (`src/version.mjs`). A default node no longer refuses a current SDK with
667
+ `422 sdk-version-absent`; the caller cannot claim a different line.
668
+ - Funding is no longer reported as complete from a sent transaction. `fundOrder` accepts only a status-1 receipt
669
+ with an `OrderCreated` from the factory that was called, for the predicted escrow; a reverted receipt is
670
+ `contract-reverted`, and a missing receipt, a missing event or a foreign event returns `pending` with a reason.
671
+ `swaps.start` refuses with `funding-pending` and does not save an escrow or retire a recovery number.
672
+ - The recovery key has a salt-based v2 derivation: `ninsei-taker-v2|<chain>|<factory>|taker|<salt>`. The client draws
673
+ a fresh public salt per order; the salt is already in `OrderCreated`, so recovery costs one derivation per order
674
+ instead of an index scan. No index is reserved, and the same words on a second device or at another factory do not
675
+ repeat a half. Existing v1 orders still recover through `ninsei-taker-v1|<chain>|<index>`.
676
+ - `swaps.start` accepts the client-owned `recoverySalt` and requires it to equal `order.salt`; without it the v1 index
677
+ path is unchanged. `newRecoverySalt` and `recoverySaltOf` are exported for the quote client.
678
+ - `sweep.restore` reports `scanComplete: false` with `scanIncompleteReason: "sync-failed"`, `"behind-node"` or
679
+ `"no-heights"` when the wallet did not reach the node's height. A zero balance after a failed sync is no longer
680
+ reported as an empty wallet.
681
+ - `findSwaps` no longer stops after five empty numbers. It searches the whole allocator range by default and
682
+ returns `complete: false` with `searchedUpTo` when a caller supplies a smaller `maxIndex`; `gap` is retained
683
+ but ignored.
418
684
  THE ENGINE MIRROR (`engine/**`) CROSSES OVER IN ENGLISH, WITHOUT THE OLD BRAND AND WITHOUT A HARDCODED DOMAIN.
419
685
  All 45 modules are copied byte-for-byte from the demo repository's `sdk/engine` (`tools/build-sdk-engine.mjs`):
420
686
  comments are concise technical English and message strings are translated one to one, `Arrakis` is renamed to
package/README.md CHANGED
@@ -17,7 +17,8 @@ machine). The wallet arrives as an EIP-1193 provider, storage as an adapter, and
17
17
  | `recovery.open` — parsing the recovery file | works |
18
18
  | `sim` — sandbox scenarios and time | works (applying to a swap — stage 2) |
19
19
  | `quotes.watch` | works: live indicative quotes, its own client; a snapshot carries the display fields (sides, networks, `why`, signature, age, maturity), and the size can be NAMED (`size`) |
20
- | `quotes.firm` | answers `not-implemented` (stage 2: signing with a key and the order protocol) |
20
+ | `quotes.firm` | works: a signed, binding quote with a TTL; a refusal is a named code |
21
+ | `quotes.firmRelay` | works: a firm quote through a relay, built from one recovery key; the node half is DLEQ-checked |
21
22
  | `swaps.list` / `swaps.get` | read state from storage (and after a page reload) |
22
23
  | `swaps.start` | holds the order: guards → recovery file and the person's confirmation → order → locking funds → **recording with us and watching the Monero address**; returns the named state `step: "xmr-incoming"` |
23
24
  | `swaps.watch` | waits for XMR to arrive at the joint address with the same request as the page (`GET /swaps/{id}`): arrived — returns the fact (amount, confirmations, txid); did not wait — a refusal with the code `xmr-timeout`; already on the address but barred by `unlock_time` — a refusal with the code `xmr-locked` (the boundary is in `params.until`), and NO ready mark; the maker DECLINED the order — returns `SwapDeclinedWatch` (`declined: true`, `terminal: "declined"`, the node's own `code`), with every action off; with expectations and a wallet, places the ready mark after the arrival |
@@ -26,6 +27,26 @@ machine). The wallet arrives as an EIP-1193 provider, storage as an adapter, and
26
27
  What is not ready names itself with the code `not-implemented` with the fields `surface` and `stage`: an empty
27
28
  value that looks like data is more dangerous than a refusal.
28
29
 
30
+ ## Settlement legs and VM profiles
31
+
32
+ The settlement side is a driver, not a chain id switch. A VM profile says what that side looks like. The profile is
33
+ metadata: it does not make a network runnable, and it does not carry a finality claim - finality is a chain policy,
34
+ not a VM shape.
35
+
36
+ | VM | Address form | Settlement | Ref | Driver today |
37
+ |---|---|---|---|---|
38
+ | `evm` | `hex-20` | contract | address | yes |
39
+ | `tron` | `base58check` | contract | address | profile only |
40
+ | `solana` | `base58` | program | PDA | profile only |
41
+ | `bitcoin` | `base58check-or-bech32-or-bech32m` | UTXO script | script | profile only |
42
+
43
+ The planned order is Tron, Solana, then Bitcoin. Dogecoin is not on that list and has no profile. A profile is not
44
+ a driver: `LEGS` still holds only `evm`, so Tron, Solana and Bitcoin are refused by name until their own work lands.
45
+
46
+ `chainConfig(...).settlementProfile` returns the profile for the network, or `null` for an unknown VM. The check
47
+ `tools/check.mjs --only=settlement-profiles` guards the table, the absence of Dogecoin and the absence of a finality
48
+ claim. A future driver adds its own settlement methods; the profile alone never authorizes a fund, claim or refund.
49
+
29
50
  ## The contract
30
51
 
31
52
  `index.d.ts` is the single source of truth about the API shape, and this is checked: `tools/check-sdk.mjs`
@@ -153,11 +174,21 @@ signature, the age (`ageMs`) and the maturity level (`finality`) — display fie
153
174
  The price does not depend on volume: the volume is needed by the provider to say whether it takes such a size. So
154
175
  the size is computed from the person's amount and is snapped to the node's grid **down**, and a size outside the
155
176
  range is not aligned: it is the provider's refusal, and it arrives as a code. Indicative quotes commit to nothing;
156
- `quotes.firm` commits (stage 2).
177
+ `quotes.firm` and `quotes.firmRelay` commit, and the relay path carries the maker's signed terms and its view half.
157
178
 
158
179
  ## Found orders and the paying token
159
180
 
160
- `findSwaps` returns the orders whose commitment came from the recovery key. Each found order reports:
181
+ `findSwaps` returns the orders whose commitment came from the recovery key. A salt-based order is found from the
182
+ public `salt` in its own `OrderCreated`: the binding is `ninsei-taker-v2|<chain>|<factory>|taker|<salt>`, so the same
183
+ words on another device or at another factory produce a different half and no number is reserved. The result says
184
+ `derivation: "salt-v2"` and leaves `index` null. Existing v1 orders stay on `ninsei-taker-v1|<chain>|<index>` and are
185
+ still found by the index path; a v2 result needs no range scan. For a v1 fallback, discovery does **not** stop after a
186
+ run of empty numbers. One call scans at most `RECOVERY_SCAN_DEFAULT` (1000) indices; that keeps a shared factory from
187
+ freezing the page. The result names where it stopped: `complete: false`, `searchedUpTo`, `incompleteReason: "max-index"`
188
+ and `nextIndex`. A caller that needs more passes `nextIndex` back as `fromIndex`; the scan yields between chunks so the
189
+ page stays responsive. `maxIndex` is the number of indices for this call, and `gap` is ignored. `onProgress` receives
190
+ `{ scanned, index }` after each chunk and after the final index; `signal` stops between chunks and returns
191
+ `incompleteReason: "aborted"` with the same resumable shape.
161
192
 
162
193
  - `amount` — the escrow's **native** amount in wei;
163
194
  - `settlementToken: "native"` — what the escrow receives;
@@ -173,15 +204,62 @@ When a token is needed, the order can be joined with an off-chain quote record b
173
204
  would have to put the token into the signed quote and the clone (and therefore a new order format and factory); only
174
205
  then can `findSwaps` return it from the chain and set `payTokenReason` to something other than `not-on-chain`.
175
206
 
207
+ `swaps.start` takes the client-owned `recoverySalt` when the signed quote already carries that salt; it must equal
208
+ `order.salt`. `newRecoverySalt()` draws a fresh 32-byte value, and `takerHalvesV2()` derives the halves without
209
+ touching the index allocator.
210
+
176
211
  The found order also carries the Monero start height and the reason it is absent. `no-block-time`,
177
212
  `monero-nodes-unreachable` and `monero-network-mismatch` are different answers: a missing settlement timestamp is not
178
213
  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
214
  path can call `recoveryIndexTaken` from `./recovery` before using a number; it checks the factory's `OrderCreated`
180
215
  logs from the registry's deployment block.
181
216
 
182
- The withdrawal window retries a `429`, a timeout or a `5xx` before it gives up. If the window still cannot be read,
217
+ A revealed half published as `h + n` (the alias the escrow accepts) is reduced modulo the secp256k1 order before the
218
+ sweep key check, so it rebuilds the same key and remains withdrawable.
219
+
220
+ `recovery.open` accepts the file as text or `{ contents }`: the envelope is parsed at the boundary, so a valid file
221
+ opens and a non-JSON one fails with its own `parse` stage.
222
+
223
+ The per-swap recovery file start path is gone: a start requires the one recovery key, shown and confirmed before
224
+ funding, and a file that could not name the escrow is no longer produced.
225
+
226
+ `moneroFeeEstimate` reports the daemon's per-byte `fee`/`fees` and its `quantization_mask`; `feeForBytes` multiplies
227
+ the price by the transaction size (1024 bytes at 20000/byte is 20,480,000).
228
+
229
+ A range scan says whether it was complete: `scanRange` returns `complete: false`, `missingHeights` and
230
+ `incompleteReason: "blocks-unreadable"` when a block, a BUSY transaction answer or a missing transaction hash leaves
231
+ the range partial.
232
+
233
+ A height lock is judged at the witnessing node's tip, not at the block that mined the transfer; confirmations still
234
+ use the inclusion height.
235
+
236
+ A split sweep reports the sum of every signed transaction's fee, not only the first; one unreadable fee makes the
237
+ total unknown.
238
+
239
+ A failed settlement block-time read keeps the matched order: `findSwaps` returns `blockTime: null` with
240
+ `startHeightReason: "block-time-read"` instead of throwing away every row.
241
+
242
+ The retired backend no longer decides a swap: its watch row may observe money, but it cannot declare readiness or
243
+ a decline; the client's two-node check and the escrow's own `Declined` event do.
244
+
245
+ Storage is a refusal, not a silent empty state: a failed read, corrupt JSON, a failed write or remove is
246
+ `storage-unavailable` with a step, and a second client with another adapter is refused rather than taking over the
247
+ first client's records.
248
+
249
+ Every body route the SDK sends carries `sdkVersion` from the package (`quotes.firm`, `quotes.sale` and the funding
250
+ mark). The caller cannot override it with a different line.
251
+
252
+ Funding is reported from the receipt, not from the send. A status-0 receipt is `contract-reverted`; a missing
253
+ receipt, a receipt without `OrderCreated`, or an event from another factory is `funding-pending` with the transaction
254
+ hash, and `swaps.start` does not save an escrow or retire a recovery number in that state.
255
+
256
+ The wallet adapter accepts the standard Monero network names (`mainnet`, `stagenet`, `testnet`, `fakechain`) and
257
+ still checks the address shape, so a mainnet withdrawal is not refused because the demo registry lists only
258
+ stagenet. The withdrawal window retries a `429`, a timeout or a `5xx` before it gives up. If the window still cannot be read,
183
259
  `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.
260
+ the node's status; a withdrawal is not refused because the node was busy. When the wallet cannot reach the node's
261
+ height, the result says `scanComplete: false` with `scanIncompleteReason: "sync-failed"`, `"behind-node"` or
262
+ `"no-heights"`; a zero balance in that state is not an empty wallet.
185
263
 
186
264
  ## Extending to other networks (Tron, Solana and beyond)
187
265
 
@@ -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() {