@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 +266 -0
- package/README.md +83 -5
- package/engine/atomic/order-worker.js +29 -2
- package/engine/atomic/order.js +24 -0
- package/engine/core/store.js +60 -12
- package/engine/evm/funding.js +65 -18
- package/engine/monero/arrivalRead.js +3 -22
- package/engine/monero/halvesSweep.js +5 -1
- package/engine/monero/scan.js +144 -33
- package/index.d.ts +154 -26
- package/package.json +35 -3
- package/src/adapters/monero-wallet.mjs +13 -6
- package/src/adapters/monero.mjs +5 -7
- package/src/codes.mjs +4 -0
- package/src/config.mjs +6 -3
- package/src/decline.mjs +6 -1
- package/src/feeEstimate.mjs +20 -13
- package/src/index.mjs +7 -4
- package/src/legs/index.mjs +8 -0
- package/src/legs/profiles.mjs +51 -0
- package/src/lock.mjs +4 -6
- package/src/order.mjs +53 -7
- package/src/public/networks.d.ts +14 -1
- package/src/public/recovery.d.ts +53 -5
- package/src/public/recovery.mjs +6 -0
- package/src/public/xmr.d.ts +26 -8
- package/src/quotes.mjs +410 -3
- package/src/recovery-key.mjs +217 -39
- package/src/recovery.mjs +9 -1
- package/src/relayFeatures.mjs +191 -0
- package/src/relayFirmClient.mjs +86 -0
- package/src/relayQuote.mjs +209 -0
- package/src/relayRequest.mjs +188 -0
- package/src/relayTerms.mjs +145 -0
- package/src/relayView.mjs +110 -0
- package/src/swap-flow.mjs +3 -2
- package/src/swaps.mjs +402 -80
- package/src/sweep-fees.mjs +32 -0
- package/src/sweep-restore.mjs +30 -9
- package/src/sweep.mjs +28 -5
- package/src/version.mjs +1 -1
- package/src/xmr-arrival.mjs +76 -32
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` |
|
|
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`
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) });
|
package/engine/atomic/order.js
CHANGED
|
@@ -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 };
|
package/engine/core/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
80
|
-
} catch (
|
|
81
|
-
|
|
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
|
-
|
|
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() {
|