@ninsei-labs/sdk 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -38,13 +38,30 @@
38
38
  "check:destination": "node tools/check-destination.mjs",
39
39
  "check:codes-meta": "node tools/check-codes-meta.mjs",
40
40
  "check:arrival-read": "node tools/check-arrival-read.mjs",
41
+ "check:sale-auth": "node tools/check-sale-auth.mjs",
42
+ "check:backup-confirmation": "node tools/check-backup-confirmation.mjs",
43
+ "check:key-view": "node tools/check-key-view.mjs",
44
+ "check:arrival-consensus": "node tools/check-arrival-consensus.mjs",
45
+ "check:arrival-real": "node tools/check-arrival-real.mjs",
46
+ "check:order-proof": "node tools/check-order-proof.mjs",
41
47
  "check:daemon-address": "node tools/check-daemon-address.mjs",
42
48
  "check:ci-scripts": "node tools/check-ci-scripts.mjs",
43
49
  "check:factory-selectors": "node tools/check-factory-selectors.mjs",
44
50
  "check:fee-estimate": "node tools/check-fee-estimate.mjs",
45
51
  "check:deadline-scale": "node tools/check-deadline-scale.mjs",
46
52
  "check:node-probe": "node tools/check-node-probe.mjs",
47
- "check:recovery-key": "node tools/check.mjs --only=recovery-key"
53
+ "check:recovery-key": "node tools/check.mjs --only=recovery-key",
54
+ "check:recovery-open": "node tools/check-recovery-open.mjs",
55
+ "check:contract-vectors": "node tools/check-contract-vectors.mjs",
56
+ "check:live-contract": "node tools/check-live-contract.mjs",
57
+ "check:anvil-fork": "node tools/check-anvil-fork.mjs",
58
+ "check:dex": "node tools/check.mjs --only=cow,cow-refusal,kyber,kyber-refusal,uniswapx,uniswapx-refusal,uniswap-relay,uniswap-relay-refusal",
59
+ "check:anvil-uniswap": "node tools/check-anvil-uniswap.mjs",
60
+ "check:atomic-flow": "node tools/check-atomic-flow.mjs",
61
+ "check:packed-consumer": "node tools/check-packed-consumer.mjs",
62
+ "check:quote-binding": "node tools/check-quote-binding.mjs",
63
+ "check:types-consumer": "node node_modules/typescript/bin/tsc -p tools/ts-consumer/tsconfig.json",
64
+ "check:monero-live": "node tools/check-monero-live.mjs"
48
65
  },
49
66
  "types": "./index.d.ts",
50
67
  "exports": {
@@ -126,5 +143,8 @@
126
143
  "@scure/bip39": "2.4.0",
127
144
  "keccak256": "^1.0.6"
128
145
  },
129
- "sideEffects": false
146
+ "sideEffects": false,
147
+ "devDependencies": {
148
+ "typescript": "7.0.2"
149
+ }
130
150
  }
@@ -25,16 +25,25 @@
25
25
  // with a code and params. Neither `null` nor a zero in place of the answer: an empty address and a zero balance
26
26
  // look like real data, and that is exactly why they are not returned.
27
27
  import { SdkError } from "../errors.mjs";
28
+ import { signedFeeTotal } from "../sweep-fees.mjs";
28
29
  import { config as engineConfig, xmrAddress } from "../engine.mjs";
29
30
 
30
31
  const XMR_VM = "monero";
31
32
  // The wallet session label - the same as the withdrawal page's. This is not a secret and not an address, but a session name.
32
33
  const DEFAULT_PASSWORD = "ninsei-wallet-session";
33
34
 
34
- /** Monero network names declared by the core's registry. The adapter keeps no table of its own. */
35
+ // THE STANDARD NAMES ARE PART OF THE PROTOCOL, NOT OF A DEPLOYMENT. XMR_NETWORKS names what the demo is configured
36
+ // for (stagenet in CI, fakechain in regtest); a mainnet or testnet wallet is still a valid Monero wallet and the
37
+ // withdrawal page takes the network from its own node. Refuse only a name that is not a Monero network at all.
38
+ const STANDARD_XMR_NETWORKS = ["mainnet", "stagenet", "testnet", "fakechain"];
39
+
40
+ /** Monero network names declared by the core's registry plus the standard names. */
35
41
  const knownNetworks = () => {
36
42
  const declared = engineConfig.XMR_NETWORKS || {};
37
- return [...new Set(Object.values(declared).filter((v) => typeof v === "string" && v))].sort();
43
+ return [...new Set([
44
+ ...Object.values(declared).filter((v) => typeof v === "string" && v),
45
+ ...STANDARD_XMR_NETWORKS,
46
+ ])].sort();
38
47
  };
39
48
 
40
49
  /**
@@ -183,11 +192,9 @@ export function moneroWalletAdapter({
183
192
  },
184
193
 
185
194
  // Optional, like every withdrawal adapter: a library that reports no fee answers null rather than a zero.
195
+ // A split sweep pays a fee on EVERY signed transaction; the total is what leaves the remainder.
186
196
  async fee(signed) {
187
- const tx = Array.isArray(signed) ? signed[0] : signed;
188
- if (!tx || typeof tx.getFee !== "function") return null;
189
- const value = tx.getFee();
190
- return value === null || value === undefined ? null : String(value);
197
+ return signedFeeTotal(signed);
191
198
  },
192
199
 
193
200
  // THE MONERO WALLET HAS NO EVENTS (monero-ts has no on/removeListener): a subscriber receives a notification
@@ -8,6 +8,7 @@
8
8
  // sending is a separate call. That way the interface has a point where it can show the person what exactly will
9
9
  // leave, BEFORE sending rather than after.
10
10
  import { SdkError } from "../errors.mjs";
11
+ import { signedFeeTotal } from "../sweep-fees.mjs";
11
12
 
12
13
  export function moneroWallet({ createWallet, networkType, serverUri, password = "ninsei-sweep-session", fields = {} }) {
13
14
  const build = typeof createWallet === "function" ? createWallet : null;
@@ -41,15 +42,12 @@ export function moneroWallet({ createWallet, networkType, serverUri, password =
41
42
  return await wallet.sweepUnlocked({ address, relay });
42
43
  },
43
44
  // THE FEE OF WHAT WAS SIGNED, when the library reports one. `sweepUnlocked` answers with the signed
44
- // transactions; monero-ts puts the fee on each transaction. Optional by contract - the core treats a missing
45
- // method as "unknown", so this adapter says what it can and nothing more.
45
+ // transactions; monero-ts puts the fee on each transaction and a split sweep pays every one of them. Optional
46
+ // by contract - the core treats a missing method as "unknown", so this adapter says what it can and nothing
47
+ // more; one unreadable fee makes the total unknown, not partial.
46
48
  async fee(signed) {
47
49
  need();
48
- const list = Array.isArray(signed) ? signed : [signed];
49
- const tx = list[0];
50
- if (!tx || typeof tx.getFee !== "function") return null;
51
- const value = tx.getFee();
52
- return value === null || value === undefined ? null : String(value);
50
+ return signedFeeTotal(signed);
53
51
  },
54
52
 
55
53
  async relay(signed) {
package/src/codes.mjs CHANGED
@@ -92,6 +92,9 @@ export const ERROR_CODES = [
92
92
  // through - server-unavailable, while the service answered and did not accept - server-refused (with the
93
93
  // status and its own reason). The advice to the interface differs: "retry" versus "investigate".
94
94
  "server-refused",
95
+ // THE TRANSACTION WAS SENT BUT THE RECEIPT DOES NOT PROVE AN ESCROW: no receipt yet, no expected OrderCreated,
96
+ // or a receipt from another factory. The tx hash is the handle for a retry read; nothing was saved as funded.
97
+ "funding-pending",
95
98
  // A TIMEOUT WAITING FOR XMR TO ARRIVE - a separate code. "We did not wait long enough" is a state of the
96
99
  // money, not a breakdown and not an empty response: mixing it with other refusals, the interface could not
97
100
  // tell "not arrived yet" from "broken".
@@ -167,6 +170,7 @@ export const ERROR_META = Object.freeze({
167
170
  "storage-unavailable": { retryable: true, side: "service" },
168
171
  "server-unavailable": { retryable: true, side: "service" },
169
172
  "server-refused": { retryable: false, side: "service" },
173
+ "funding-pending": { retryable: true, side: "chain" },
170
174
  "xmr-timeout": { retryable: true, side: "chain" },
171
175
  "xmr-locked": { retryable: true, side: "chain" },
172
176
  "xmr-nodes-disagree": { retryable: true, side: "chain" },
package/src/config.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import * as engine from "./engine.mjs";
9
9
  import { CHECK_CODES, ERROR_CODES, STEP_CODES } from "./codes.mjs";
10
10
  import { fail } from "./errors.mjs";
11
- import { implementedVms, legFor } from "./legs/index.mjs";
11
+ import { implementedVms, legFor, profileOfChain } from "./legs/index.mjs";
12
12
  import { routeProviders } from "./legs/evm.mjs";
13
13
 
14
14
  // TOKENS ARE TAKEN BY AN ENGINE FUNCTION, NOT FROM A NETWORK FIELD: `tokensOf` returns the native coin first,
@@ -41,7 +41,10 @@ const tokensOfNetwork = (chainId) => {
41
41
  const chainConfig = (chain) => ({
42
42
  id: chain.id,
43
43
  name: chain.name,
44
- vm: chain.vm || "evm",
44
+ vm: chain.vm || null,
45
+ // WHAT THIS SETTLEMENT SIDE LOOKS LIKE: address format, contract/program/UTXO and ref kind. It is metadata and
46
+ // does not claim a driver exists; `implementedVms` and `legFor` decide that. Finality belongs to a chain.
47
+ settlementProfile: profileOfChain(chain.id),
45
48
  chainId: chain.chainId,
46
49
  nativeSymbol: chain.native && chain.native.symbol ? chain.native.symbol : null,
47
50
  tokens: tokensOfNetwork(chain.chainId).map((t) => ({
@@ -108,7 +111,7 @@ export const DEFAULT_XMR_NODES = Object.freeze({
108
111
  { url: "https://mainnet.xmr.kernal.eu:18089", operator: "kernal.eu" },
109
112
  ],
110
113
  stagenet: [
111
- { url: "https://xmr.arrakisswap.trade", operator: "ninseiswap" },
114
+ { url: "https://xmr-stagenet.ninsei.finance", operator: "Ninsei" },
112
115
  { url: "https://stagenet.xmr.kernal.eu:38089", operator: "kernal.eu" },
113
116
  ],
114
117
  });
package/src/decline.mjs CHANGED
@@ -41,7 +41,12 @@ const hexOf = (bytes) => {
41
41
  return out;
42
42
  };
43
43
 
44
- /** keccak256("Declined(address)") - the first (and only) topic of a decline's log. */
44
+ /**
45
+ * keccak256("Declined(address)") - topic0 of a decline's log. The escrow indexes `claimer`, so a log emitted by the
46
+ * current contracts also carries it in topic1; the event signature (and therefore topic0) does not change. Logs from
47
+ * escrows deployed before the indexing carry the claimer in data instead, which is why this module matches only
48
+ * topic0 and does not assume a claimer position.
49
+ */
45
50
  export const DECLINED_TOPIC0 = hexOf(keccak256(DECLINE_EVENT_SIGNATURE()));
46
51
 
47
52
  // THE NODE'S OWN TWO FIELDS for a declined swap: `{ status: "declined", code: "compliance" }`. The code is the
@@ -13,13 +13,16 @@ const DEFAULT_TIMEOUT_MS = 8000;
13
13
 
14
14
  const text = (value) => (typeof value === "string" ? value.trim() : "");
15
15
 
16
- /** A per-KB price -> the fee for a transaction of `bytes`, rounded UP: one unit short is still short. */
17
- export function feeForBytes(feePerKB, bytes) {
18
- let perKB;
16
+ /**
17
+ * A PER-BYTE price -> the fee for a transaction of `bytes`/weight. Monero's `get_fee_estimate` returns `fee` and
18
+ * `fees` in atomic units per byte; dividing by 1024 made every estimate 1024 times too low.
19
+ */
20
+ export function feeForBytes(feePerByte, bytes) {
21
+ let perByte;
19
22
  let size;
20
- try { perKB = BigInt(feePerKB); size = BigInt(bytes); } catch { throw new RangeError("feeForBytes: whole numbers only"); }
21
- if (perKB < 0n || size < 0n) throw new RangeError("feeForBytes: negative input");
22
- return ((perKB * size + 1023n) / 1024n).toString();
23
+ try { perByte = BigInt(feePerByte); size = BigInt(bytes); } catch { throw new RangeError("feeForBytes: whole numbers only"); }
24
+ if (perByte < 0n || size < 0n) throw new RangeError("feeForBytes: negative input");
25
+ return (perByte * size).toString();
23
26
  }
24
27
 
25
28
  const atomicField = (source, ...names) => {
@@ -34,8 +37,9 @@ const atomicField = (source, ...names) => {
34
37
  /**
35
38
  * ASK A NODE WHAT A TRANSACTION WILL COST.
36
39
  *
37
- * The answer is a price per kilobyte (and the median, and the daemon's own priority list). The exact sum needs the
38
- * assembled transaction; `feeForBytes` is the arithmetic that gets there. Never throws - a refusal names the problem.
40
+ * The answer is a price per byte (and the daemon's own priority list, and its quantization mask). The exact sum
41
+ * needs the assembled transaction; `feeForBytes` is the arithmetic that gets there. Never throws - a refusal names
42
+ * the problem.
39
43
  */
40
44
  export async function moneroFeeEstimate({ url, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
41
45
  // TRAILING SLASHES WITHOUT A REGEX: a pattern anchored on a run of slashes is flagged as a slow expression on
@@ -65,9 +69,12 @@ export async function moneroFeeEstimate({ url, fetchImpl = globalThis.fetch, tim
65
69
  try { body = await response.json(); } catch { return { ok: false, why: "not-json" }; }
66
70
  if (!body || body.error) return { ok: false, why: "rpc", rpc: body && body.error ? body.error.code ?? null : null };
67
71
  const result = body.result || {};
68
- const feePerKB = atomicField(result, "fee_per_kb", "feePerKB");
69
- const medianFeePerKB = atomicField(result, "median_fee_per_kb", "medianFeePerKB");
70
- const list = result.fees ?? result.feesPerKB ?? null;
72
+ // THE DAEMON'S OWN UNITS. `fee` and each entry of `fees` are atomic units PER BYTE; `quantization_mask` is the
73
+ // unit the final fee is rounded to. `fee_per_kb` was never sent, so the old read found nothing on a real node and
74
+ // the old fallback then divided per-byte values by 1024.
75
+ const feePerByte = atomicField(result, "fee", "feePerByte");
76
+ const quantizationMask = atomicField(result, "quantization_mask", "quantizationMask");
77
+ const list = result.fees ?? result.feesPerByte ?? null;
71
78
  let perPriority = null;
72
79
  if (Array.isArray(list)) {
73
80
  perPriority = [];
@@ -75,6 +82,6 @@ export async function moneroFeeEstimate({ url, fetchImpl = globalThis.fetch, tim
75
82
  try { perPriority.push(BigInt(value).toString()); } catch { perPriority = null; break; }
76
83
  }
77
84
  }
78
- if (feePerKB === null && medianFeePerKB === null && !perPriority) return { ok: false, why: "no-estimate" };
79
- return { ok: true, url: base, feePerKB, medianFeePerKB, perPriority };
85
+ if (feePerByte === null && !perPriority) return { ok: false, why: "no-estimate" };
86
+ return { ok: true, url: base, feePerByte, perPriority, quantizationMask };
80
87
  }
package/src/index.mjs CHANGED
@@ -198,10 +198,11 @@ export function createNinsei(options) {
198
198
  // THE STORAGE THE INTERFACE BROUGHT BECOMES THE CORE'S STORAGE: unfinished swaps and the agent session live
199
199
  // where the interface decided (browser storage, memory, a server). Without the option the core takes its
200
200
  // default: web storage if it is present in the environment, otherwise memory.
201
- if (o.storage) {
202
- engine.store.setStorage(o.storage);
203
- engine.auth.setSessionStore(o.storage);
204
- }
201
+ // ONE CLIENT, ONE STORE. A second client with another adapter used to replace the module-wide store, so the
202
+ // first client listed the second's swaps and could delete them. Even a client that brought no adapter binds the
203
+ // current one, so a later explicit store cannot take it over.
204
+ engine.store.bindStorage(o.storage || engine.store.storage());
205
+ if (o.storage) engine.auth.setSessionStore(o.storage);
205
206
  const http = createHttp({ apiBase: config.apiBase, fetchImpl: o.fetch, timeoutMs: config.limits.httpTimeoutMs });
206
207
  // THE CHAIN READ AND THE ALLOW-LIST GO INTO THE QUOTE CHECK: without the read the signature certifies itself,
207
208
  // so the same evmCall seam the lock and the actions use is handed to the quotes too.
@@ -10,6 +10,8 @@
10
10
  // What must not be in a driver: no user strings, no DOM. A driver is chain reads and writes plus knowing what the
11
11
  // network is measured in for this VM.
12
12
  import * as evm from "./evm.mjs";
13
+ export { profileOf, VM_PROFILES } from "./profiles.mjs";
14
+ import { profileOf } from "./profiles.mjs";
13
15
 
14
16
  export const LEGS = { evm };
15
17
 
@@ -37,6 +39,12 @@ export const vmOfChain = (chainId) => {
37
39
  return chain && chain.vm ? chain.vm : null;
38
40
  };
39
41
 
42
+ /** The VM profile for a network, or null when the network or the profile is unknown. */
43
+ export const profileOfChain = (chainId) => {
44
+ const vm = vmOfChain(chainId);
45
+ return vm ? profileOf(vm) : null;
46
+ };
47
+
40
48
  /**
41
49
  * The leg driver for a network. A network without a driver is a NAMED refusal: you can see which vm is not
42
50
  * supported and which are. This is exactly the rule "no network is silently assumed to be EVM".
@@ -0,0 +1,51 @@
1
+ // WHAT A SETTLEMENT SIDE LOOKS LIKE, WITHOUT ASSERTING THAT A DRIVER EXISTS.
2
+ //
3
+ // The profile is metadata, not an implementation. It tells the SDK and the interface which address and transaction
4
+ // shapes a VM uses, and whether settlement is a contract, a program or a UTXO script. The driver registry
5
+ // (./index.mjs) stays the source of truth for what is RUNNABLE today.
6
+ //
7
+ // Bitcoin is listed because the planned order is Tron, Solana, then Bitcoin; a UTXO settlement side is a
8
+ // script/HTLC, not an account with an escrow contract. A profile is not a promise that the chain is selectable:
9
+ // `escrow: null` and the driver registry decide that. Finality is not here: it is a chain policy, not a VM shape,
10
+ // and a generic "12 confirmations" is wrong for an L2. Add it to the chain that first needs it.
11
+ export const VM_PROFILES = Object.freeze({
12
+ // EVM: 20-byte hex addresses, a contract escrow, a transaction hash.
13
+ evm: Object.freeze({
14
+ vm: "evm",
15
+ addressFormat: "hex-20",
16
+ settlementKind: "contract",
17
+ refKind: "address",
18
+ txRef: "hash",
19
+ }),
20
+ // TVM: base58check addresses on the wire (with a 0x41 hex form internally), a contract escrow and energy/bandwidth
21
+ // instead of gas.
22
+ tron: Object.freeze({
23
+ vm: "tron",
24
+ addressFormat: "base58check",
25
+ settlementKind: "contract",
26
+ refKind: "address",
27
+ txRef: "hash",
28
+ }),
29
+ // SVM: base58 addresses, a program with PDA accounts, a transaction signature.
30
+ solana: Object.freeze({
31
+ vm: "solana",
32
+ addressFormat: "base58",
33
+ settlementKind: "program",
34
+ refKind: "pda",
35
+ txRef: "signature",
36
+ }),
37
+ // UTXO: no account and no escrow contract. Settlement is an HTLC/adaptor-script output, addressed by a script
38
+ // or an address, and identified by a txid. A future Bitcoin-family chain uses the same shape with its own
39
+ // network parameters. Taproot outputs (the likely shape of an adaptor-signature leg) are bech32m (BIP350), so
40
+ // that format is named here next to the legacy bech32 (v0) and base58check forms.
41
+ bitcoin: Object.freeze({
42
+ vm: "bitcoin",
43
+ addressFormat: "base58check-or-bech32-or-bech32m",
44
+ settlementKind: "utxo-script",
45
+ refKind: "script",
46
+ txRef: "txid",
47
+ }),
48
+ });
49
+
50
+ /** The profile for a VM, or null. An unknown VM is never silently treated as EVM. */
51
+ export const profileOf = (vm) => VM_PROFILES[vm] || null;
package/src/lock.mjs CHANGED
@@ -99,12 +99,10 @@ export async function send(request, wallet, deps = {}) {
99
99
  send: (tx) => wallet.send(tx),
100
100
  // NORMALISING THE RECEIPT ARGUMENT'S SHAPE - HERE, AT THE CORE'S BOUNDARY, NOT AT EVERY CALLER.
101
101
  //
102
- // www/js/evm/funding.js calls the receipt with an OBJECT { hash } (waitReceipt), while the adapter contract
103
- // declares receipt(txHash: string). A strict adapter (sdk/src/adapters/evm-wallet.mjs) answers bad-input to
104
- // an object, the refusal is SWALLOWED by the receipt wait, and the escrow address is silently replaced with
105
- // the PREDICTED one - that is, the "address from the receipt" check stops working without saying so. We
106
- // normalise the argument to a string here: funding.js is foreign code and must not be edited, and every
107
- // adapter should not repeat the normalisation.
102
+ // funding.js calls the receipt with an OBJECT { hash } (waitReceipt), while the adapter contract declares
103
+ // receipt(txHash: string). A strict adapter (sdk/src/adapters/evm-wallet.mjs) answers bad-input to an object,
104
+ // the refusal is SWALLOWED by the receipt wait, and the address would silently fall back. We normalise the
105
+ // argument to a string at this boundary so every adapter sees one shape.
108
106
  receipt: (arg) => wallet.receipt(typeof arg === "string" ? arg : (arg && typeof arg.hash === "string" ? arg.hash : arg)),
109
107
  });
110
108
  } catch (error) {
package/src/order.mjs CHANGED
@@ -1,15 +1,18 @@
1
1
  // THE COUNTERPARTY'S SIDE AND THE JOINT ADDRESS.
2
2
  //
3
- // TWO CHECKS, BOTH MANDATORY BEFORE SIGNING - after it there is nothing to fix:
3
+ // THREE CHECKS, ALL MANDATORY BEFORE SIGNING - after it there is nothing to fix:
4
4
  //
5
5
  // 1) the DLEQ proof stands in the CONTEXT OF THIS order: the counterparty proved for our terms, not for others.
6
6
  // The context is the same one the quote signature is bound to (see quotes.firm);
7
7
  // 2) the proven ed25519 point is THE VERY ONE that will go into the contract and from which the address is
8
8
  // assembled. Without this check the counterparty could prove one thing and substitute another: on our side
9
9
  // everything would look sound, and the discrepancy would surface when claiming the XMR, with nothing left to
10
- // fix.
10
+ // fix;
11
+ // 3) the proven secp256k1 point matches `commitHalfClaimer`, the commitment the contract compares on claim and
12
+ // refund. A proof without this comparison binds the wrong half and the escrow rejects it after the other side
13
+ // has paid.
11
14
  //
12
- // Both are done by the engine (order.js: the verifying code is the same for us and for the counterparty). The
15
+ // All three are done by the engine (order.js: the verifying code is the same for us and for the counterparty). The
13
16
  // core has no cryptography of its own and should not: a copy would silently drift from the original.
14
17
  import { SdkError } from "./errors.mjs";
15
18
  import { primitives } from "./primitives.mjs";
@@ -86,6 +89,43 @@ const sameFormAsPoint = (proof) => (proof && typeof proof === "object" && proof.
86
89
  // "no point", not "point in another form".
87
90
  const pointForm = (v) => (typeof v === "string" && v ? norm(v) : v);
88
91
 
92
+ // THE COUNTERPARTY'S VIEW MATERIAL HAS TWO SPELLINGS, AND THEY MUST AGREE. A quote carries `viewHalfClaimer`
93
+ // (and sometimes `viewHalf`), a point as `viewPoint` or `viewPub`. The guard and the builder used to prefer
94
+ // different names: the guard took the half first and ignored the point, the builder took the point first. A
95
+ // counterparty could then supply a half and a different point, the address was built from one and the recovery
96
+ // words from the other. Normalise once, refuse conflicting aliases, and derive the point from the half when only
97
+ // the half is present.
98
+ export function normalizeCounterpartyView(counterparty) {
99
+ const cp = counterparty && typeof counterparty === "object" ? counterparty : {};
100
+ const halves = [cp.viewHalf, cp.viewHalfClaimer].filter((v) => v !== undefined && v !== null && v !== "");
101
+ let half = null;
102
+ if (halves.length) {
103
+ try { half = BigInt(String(halves[0])); } catch { throw new SdkError("bad-input", { field: "counterparty", why: "view-half" }); }
104
+ for (const other of halves.slice(1)) {
105
+ let n = null;
106
+ try { n = BigInt(String(other)); } catch { n = null; }
107
+ if (n === null || n !== half) throw new SdkError("bad-input", { field: "counterparty", why: "conflicting-view-half" });
108
+ }
109
+ }
110
+ const points = [cp.viewPoint, cp.viewPub].filter((v) => v !== undefined && v !== null && v !== "");
111
+ let point = null;
112
+ if (points.length) {
113
+ point = pointForm(points[0]);
114
+ if (!isPoint(point)) throw new SdkError("bad-input", { field: "counterparty", why: "view-point" });
115
+ for (const other of points.slice(1)) {
116
+ if (pointForm(other) !== point) throw new SdkError("bad-input", { field: "counterparty", why: "conflicting-view-point" });
117
+ }
118
+ }
119
+ if (half !== null) {
120
+ let derived = null;
121
+ try { derived = pointForm(builder().pointOf(half)); } catch { derived = null; }
122
+ if (!derived) throw new SdkError("bad-input", { field: "counterparty", why: "view-half" });
123
+ if (point !== null && point !== derived) throw new SdkError("bad-input", { field: "counterparty", why: "view-half-point-mismatch" });
124
+ point = derived;
125
+ }
126
+ return { half: half === null ? null : halves[0], point };
127
+ }
128
+
89
129
  /**
90
130
  * Our side of the order. It is built by the engine's builder: two halves, their points, the DLEQ PROOF (it is
91
131
  * bound to the order terms and so is not computed without the context) and the side's encryption key.
@@ -136,11 +176,12 @@ export function acceptCounterparty({ context, side = null, counterparty = null,
136
176
  // (rfq/orderQuote.mjs). While only `edPub`/`viewPub` were accepted, the facade's own side was NOT accepted:
137
177
  // start answered bad-input { field: "counterparty", missing: ["edPub"] } to its own answer. The engine knew
138
178
  // this mapping (sdk/src/swap-flow.mjs: edPub: quote.edPointClaimer). We accept BOTH names.
179
+ const view = normalizeCounterpartyView(counterparty);
139
180
  accepted = {
140
181
  proof: sameFormAsPoint(counterparty.proof),
141
182
  edPub: pointForm(counterparty.edPub || counterparty.edPoint || counterparty.edPointClaimer || pub.ed || null),
142
- viewPoint: pointForm(counterparty.viewPoint || counterparty.viewPub || null),
143
- viewHalf: counterparty.viewHalf || counterparty.viewHalfClaimer || null,
183
+ viewPoint: view.point,
184
+ viewHalf: view.half,
144
185
  // THE COUNTERPARTY'S SPEND HALF: HIS commitment (commitHalfClaimer) is computed from it where it did not
145
186
  // arrive ready-made. Without the half the commitment cannot be obtained - and that is named null, not invented.
146
187
  half: counterparty.half !== undefined ? counterparty.half : null,
@@ -155,13 +196,18 @@ export function acceptCounterparty({ context, side = null, counterparty = null,
155
196
  // not what the page runs on.
156
197
  const mock = builder().newSide(context, true);
157
198
  accepted = { proof: sameFormAsPoint(mock.proof), edPub: norm(mock.pub.ed), viewPoint: norm(mock.viewPub),
158
- viewHalf: mock.viewHalf, half: mock.half, commitHalfClaimer: null, sealed: null, providerId: null, standIn: true, mock };
199
+ viewHalf: mock.viewHalf, half: mock.half,
200
+ // THE STAND-IN DOES CARRY A COMMITMENT: it is derived from the half it just built, by the same formula
201
+ // the contract uses. The proof check below is therefore the real one, not a path that skips it.
202
+ commitHalfClaimer: norm(builder().halfCommitment(mock.half)), sealed: null, providerId: null, standIn: true, mock };
159
203
  } else {
160
204
  throw new SdkError("bad-input", { field: "counterparty" });
161
205
  }
162
206
  const missing = [];
163
207
  if (!accepted.proof) missing.push("proof");
164
208
  if (!accepted.edPub) missing.push("edPub");
209
+ // THE COMMITMENT IS NOT OPTIONAL. Without it the proof says nothing about the half the contract will compare.
210
+ if (!accepted.commitHalfClaimer) missing.push("commitHalfClaimer");
165
211
  if (missing.length) throw new SdkError("bad-input", { field: "counterparty", missing });
166
212
  // OPENING THE SEALED PACKAGE - BY THE SAME ENGINE CALL, and the key is OURS (the package is addressed to us).
167
213
  // The envelope's composition is locked separately (www/js/atomic/halfenc.js, HALFENC_LOCK_REASON): in the live
@@ -169,7 +215,7 @@ export function acceptCounterparty({ context, side = null, counterparty = null,
169
215
  // with a refusal rather than passing itself off as a working key handover.
170
216
  const ownEncPriv = accepted.sealed && side && side.enc ? side.enc.priv : null;
171
217
  let verdict = null;
172
- try { verdict = builder().verifySide({ proof: accepted.proof, pub: { ed: accepted.edPub } }, context, accepted.sealed, ownEncPriv); }
218
+ try { verdict = builder().verifySide({ proof: accepted.proof, pub: { ed: accepted.edPub }, commitHalfClaimer: accepted.commitHalfClaimer }, context, accepted.sealed, ownEncPriv); }
173
219
  catch { throw new SdkError("bad-input", { field: "counterparty" }); }
174
220
  if (!verdict || verdict.ok !== true) {
175
221
  throw new SdkError("quote-refused", { step: "proof", why: verdict && verdict.reason ? "rejected" : "unknown" });
@@ -21,10 +21,23 @@ export interface ChainUniswap {
21
21
  [field: string]: unknown;
22
22
  }
23
23
 
24
+ /** What a settlement side looks like: address format, account/UTXO settlement and reference shape. Metadata, not a
25
+ * claim that a driver exists. */
26
+ export interface SettlementProfile {
27
+ vm: string;
28
+ addressFormat: string;
29
+ settlementKind: "contract" | "program" | "utxo-script" | string;
30
+ refKind: "address" | "pda" | "script" | string;
31
+ txRef: "hash" | "signature" | "txid" | string;
32
+ }
33
+
24
34
  export interface Chain {
25
35
  id: string;
26
36
  name: string;
27
- vm: string;
37
+ /** A registry chain names its VM; the type allows null so an unknown network is never read as EVM. */
38
+ vm: string | null;
39
+ /** Present when the VM has a profile; unknown VMs are null, never silently EVM. */
40
+ settlementProfile?: SettlementProfile | null;
28
41
  chainId: number;
29
42
  caip2?: string;
30
43
  testnet?: boolean;
@@ -275,6 +275,8 @@ export declare const RECOVERY_KEY_STORE: string;
275
275
  export declare const RECOVERY_KEY_FILE_MAGIC: string;
276
276
  /** The domain half derivation binds to; the chain id and swap number join it. */
277
277
  export declare const RECOVERY_KEY_DOMAIN: string;
278
+ /** The salt-based derivation domain; the chain, factory, role and order salt join it. */
279
+ export declare const RECOVERY_KEY_DOMAIN_V2: string;
278
280
  /** The domain of the counterparty's view half, derived from the same one key. */
279
281
  export declare const MAKER_VIEW_HALF_DOMAIN: string;
280
282
  /** The encrypted backup envelope version. */
@@ -290,6 +292,8 @@ export interface RecoveryKey {
290
292
  readonly mnemonic: string;
291
293
  readonly fingerprint: string;
292
294
  readonly createdAt: string;
295
+ /** True only after the person confirmed that the key is saved; a key in storage is not proof of that. */
296
+ readonly backupConfirmed?: boolean;
293
297
  }
294
298
 
295
299
  /** A derived swap number: the halves cross JSON as hex scalars. */
@@ -305,6 +309,15 @@ export interface TakerHalves {
305
309
  readonly spendCommitment: string;
306
310
  }
307
311
 
312
+ /** A salt-based derivation: no number is reserved, and the public order salt is the input. */
313
+ export interface TakerHalvesV2 extends Omit<TakerHalves, "index"> {
314
+ readonly index: null;
315
+ readonly derivation: "salt-v2";
316
+ readonly factory: string;
317
+ readonly role: string;
318
+ readonly salt: string;
319
+ }
320
+
308
321
  /** One order found by the key's commitments. */
309
322
  export interface FoundSwap {
310
323
  readonly escrow: string;
@@ -338,12 +351,17 @@ export interface FoundSwap {
338
351
  * the expected clone. */
339
352
  readonly xmrAmount: string | null;
340
353
  readonly xmrAmountReason: "event" | "no-code-reader" | "code" | "not-clone" | null;
341
- readonly index: number;
354
+ /** The v1 swap number; null for a salt-based v2 order. */
355
+ readonly index: number | null;
356
+ /** Which derivation found this order. v1 orders keep recovering through the index path. */
357
+ readonly derivation: "index-v1" | "salt-v2";
358
+ /** The factory the order was read from; the v2 binding needs it. */
359
+ readonly factory: string;
342
360
  readonly side: "locker" | "claimer";
343
361
  /** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
344
362
  * not pass the readers; `startHeightReason` then names why. */
345
363
  readonly startHeight: number | null;
346
- readonly startHeightReason: "no-block-number" | "no-height-readers" | "monero-nodes-unreachable" | "monero-network-mismatch" | "no-block-time" | "monero-header" | null;
364
+ readonly startHeightReason: "no-block-number" | "no-height-readers" | "monero-nodes-unreachable" | "monero-network-mismatch" | "no-block-time" | "block-time-read" | "monero-header" | null;
347
365
  }
348
366
 
349
367
  /** A storage adapter: the same shape the SDK store takes. */
@@ -361,8 +379,16 @@ export declare function importRecoveryKey(mnemonic: string, options?: { createdA
361
379
  export declare function fingerprintOf(mnemonic: string): string;
362
380
  /** The binding for a chain and swap number: `ninsei-taker-v1|<chainId>|<index>`. */
363
381
  export declare function recoveryBinding(chainId: number, index: number): string;
382
+ /** The salt-based binding: `ninsei-taker-v2|<chainId>|<factory>|<role>|<salt>`. */
383
+ export declare function recoveryBindingV2(request: { chainId: number; factory: string; role?: string; salt: string }): string;
384
+ /** Normalises a 32-byte salt from an event word, a hex string, a bigint or a safe integer. */
385
+ export declare function recoverySaltOf(value: string | number | bigint): string;
386
+ /** A fresh 32-byte public salt for one salt-based order. */
387
+ export declare function newRecoverySalt(): string;
364
388
  /** Derives the taker spend and view halves for one swap number. */
365
389
  export declare function takerHalves(request: { mnemonic: string; chainId: number; index: number }): TakerHalves;
390
+ /** Derives the taker halves from a public per-order salt; no index is reserved. */
391
+ export declare function takerHalvesV2(request: { mnemonic: string; chainId: number; factory: string; role?: string; salt: string }): TakerHalvesV2;
366
392
  /** The stable backup name, with `DO-NOT-DELETE` in it on purpose. */
367
393
  export declare function recoveryKeyFileName(request: { mnemonic?: string; fingerprint?: string; createdAt?: string | Date }): string;
368
394
  /** A plain backup file: readable header above the words. */
@@ -372,9 +398,9 @@ export declare function createRecoveryKeyFile(request: { mnemonic: string; creat
372
398
  /** Reads a plain or encrypted backup file and validates the words. */
373
399
  export declare function openRecoveryKeyFile(contents: string, passphrase?: string): Promise<RecoveryKey>;
374
400
  /** Saves the key through a storage adapter. */
375
- export declare function saveRecoveryKey(storage: RecoveryKeyStorage, key: RecoveryKey & { nextIndex?: number; reserved?: Record<string, unknown>; retired?: Record<string, unknown> }): RecoveryKey;
401
+ export declare function saveRecoveryKey(storage: RecoveryKeyStorage, key: RecoveryKey & { nextIndex?: number; reserved?: Record<string, unknown>; retired?: Record<string, unknown>; backupConfirmed?: boolean }): RecoveryKey;
376
402
  /** Reads the key from a storage adapter, or null. */
377
- export declare function loadRecoveryKey(storage: RecoveryKeyStorage): (RecoveryKey & { nextIndex: number; reserved: Record<string, unknown>; retired: Record<string, unknown> }) | null;
403
+ export declare function loadRecoveryKey(storage: RecoveryKeyStorage): (RecoveryKey & { nextIndex: number; reserved: Record<string, unknown>; retired: Record<string, unknown>; backupConfirmed?: boolean }) | null;
378
404
  /** Reserves a swap number; the caller releases it if the request is abandoned. */
379
405
  export declare function reserveRecoveryIndex(storage: RecoveryKeyStorage, request: { chainId: number; swapId?: string | null }): number;
380
406
  /** True when an order already holds this number's commitment on chain. A page that starts swaps without the SDK's
@@ -403,7 +429,9 @@ export declare function findSwaps(request: {
403
429
  /** The factory's deployment block from the chain registry; used when `fromBlock` is omitted. */
404
430
  deploymentBlock?: number | null;
405
431
  toBlock?: number | string;
432
+ /** Deprecated and ignored: discovery no longer stops after a run of empty numbers. */
406
433
  gap?: number;
434
+ /** The last index to try. Defaults to the allocator's ceiling; a smaller value returns `complete: false`. */
407
435
  maxIndex?: number;
408
436
  /** Reads the settlement block timestamp, in seconds: (blockNumber) -> seconds. */
409
437
  evmBlockTime?: (blockNumber: number) => Promise<number>;
@@ -420,4 +448,13 @@ export declare function findSwaps(request: {
420
448
  moneroNetwork?: string | null;
421
449
  /** The fetch used for those node requests; absent - the global fetch. */
422
450
  fetchImpl?: typeof fetch;
423
- }): Promise<{ swaps: FoundSwap[]; scanned: number }>;
451
+ }): Promise<{
452
+ swaps: FoundSwap[];
453
+ scanned: number;
454
+ /** The last index tried, or null when no index was scanned. */
455
+ searchedUpTo: number | null;
456
+ /** True only when the whole allocator range was searched. */
457
+ complete: boolean;
458
+ /** `max-index` when a smaller caller-supplied bound stopped the search. */
459
+ incompleteReason?: string;
460
+ }>;
@@ -25,6 +25,7 @@ export {
25
25
  export {
26
26
  RECOVERY_KEY_ALG,
27
27
  RECOVERY_KEY_DOMAIN,
28
+ RECOVERY_KEY_DOMAIN_V2,
28
29
  RECOVERY_KEY_ENVELOPE_VERSION,
29
30
  RECOVERY_KEY_FILE_MAGIC,
30
31
  RECOVERY_KEY_ITER,
@@ -39,13 +40,17 @@ export {
39
40
  fingerprintOf,
40
41
  importRecoveryKey,
41
42
  loadRecoveryKey,
43
+ newRecoverySalt,
42
44
  openRecoveryKeyFile,
43
45
  recoveryBinding,
46
+ recoveryBindingV2,
44
47
  recoveryIndexTaken,
48
+ recoverySaltOf,
45
49
  recoveryKeyFileName,
46
50
  recoveryKeyText,
47
51
  releaseRecoveryIndex,
48
52
  reserveRecoveryIndex,
49
53
  saveRecoveryKey,
50
54
  takerHalves,
55
+ takerHalvesV2,
51
56
  } from "../recovery-key.mjs";