@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/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // 2) not a single DOM access: `document`, `window`, `localStorage` do not occur in the SDK. Storage and the wallet
10
10
  // come from outside: as an adapter and an EIP-1193 provider;
11
11
  // 3) the SDK opens no windows: the interface shows the wallet modal, the SDK accepts a ready provider;
12
- // 4) funds are not locked until the interface has confirmed the recovery file is saved (onRecoveryFile);
12
+ // 4) funds are not locked until the interface has confirmed the one recovery key is saved (onRecoveryKey);
13
13
  // 5) the guards cannot be bypassed: preflight is part of the same path, not a separate button.
14
14
  //
15
15
  // Naming language: the values are data. `step` and `code` are stable strings from the tables below; they are
@@ -288,7 +288,15 @@ export interface SdkConfig {
288
288
  export interface ChainConfig {
289
289
  readonly id: string;
290
290
  /** The network's virtual machine: "evm" today; "tron" and "solana" - when their leg drivers appear. */
291
- readonly vm: string;
291
+ readonly vm: string | null;
292
+ /** What this settlement side looks like. Metadata only: it does not claim that a driver exists. */
293
+ readonly settlementProfile?: {
294
+ readonly vm: string;
295
+ readonly addressFormat: string;
296
+ readonly settlementKind: "contract" | "program" | "utxo-script" | string;
297
+ readonly refKind: "address" | "pda" | "script" | string;
298
+ readonly txRef: "hash" | "signature" | "txid" | string;
299
+ } | null;
292
300
  readonly chainId: number;
293
301
  readonly name: string;
294
302
  readonly nativeSymbol: string;
@@ -446,6 +454,11 @@ export interface QuotesApi {
446
454
  snapshot(): QuoteSnapshot | null;
447
455
  /** A binding quote with a TTL. A refusal comes as a code, not text. */
448
456
  firm(offer: FirmOffer): Promise<Quote>;
457
+ /**
458
+ * A firm quote through a relay, built from one recovery key. The relay answer is verified against the
459
+ * requested terms and the on-chain registry/factory binding before it is returned.
460
+ */
461
+ firmRelay(request: FirmRelayOffer): Promise<FirmRelayAnswer>;
449
462
  /**
450
463
  * A reverse-mode ticket: the maker sells XMR. The request is the node's `request` object; the answer carries the
451
464
  * signed order quote and its claim tip, which is checked against the gas price before it is returned.
@@ -652,11 +665,60 @@ export interface QuoteReference {
652
665
  }
653
666
 
654
667
  export interface FirmOffer {
655
- readonly direction: "buy" | "sell";
656
- readonly token: string;
657
- readonly amount: number;
658
- readonly receiveTo: string;
659
- readonly providerId?: string;
668
+ /** Who gives the quote. Without it there is no one to ask. */
669
+ readonly providerId: string;
670
+ /** The order terms the signature is bound to. */
671
+ readonly order: Readonly<Record<string, unknown>>;
672
+ /** The taker's maker view half when the order was built from a recovery key. */
673
+ readonly viewHalfClaimer?: string;
674
+ }
675
+
676
+ export interface FirmRelayOffer {
677
+ /** Relay origin, without the `/v1/quote` path. */
678
+ readonly relayUrl: string;
679
+ /** The signed public features the relay forwarded; they are verified before anything is sent. */
680
+ readonly features: Readonly<Record<string, unknown>>;
681
+ readonly order: Readonly<Record<string, unknown>>;
682
+ readonly side: "ask" | "bid";
683
+ /** The twelve-word recovery key or an object carrying it. */
684
+ readonly recoveryKey: string | { readonly mnemonic: string };
685
+ /** A fresh salt the client owns; it must equal `order.salt`. Defaults to `order.salt`. */
686
+ readonly recoverySalt?: string;
687
+ /** The v1 index; it is mutually exclusive with `recoverySalt`. */
688
+ readonly recoveryIndex?: number;
689
+ /** The market's chain and asset, for example `421614:ETH`. Derived when exactly one window matches. */
690
+ readonly market?: string;
691
+ readonly xmrNetwork?: string;
692
+ readonly xmrAmount: string | number;
693
+ readonly claimTip?: string | number;
694
+ readonly partner?: string;
695
+ readonly fetchImpl?: typeof fetch;
696
+ readonly timeoutMs?: number;
697
+ }
698
+
699
+ export interface FirmRelayAnswer {
700
+ readonly quote: Readonly<Record<string, unknown>>;
701
+ readonly orderQuote: Readonly<Record<string, unknown>>;
702
+ /** The exact order terms the quote is bound to. */
703
+ readonly order: Readonly<Record<string, unknown>>;
704
+ readonly nodeHalf: Readonly<Record<string, unknown>>;
705
+ /** Ready to pass as `swaps.start({ counterparty })`: proof, claimer commitment and maker view half. */
706
+ readonly counterparty: Readonly<Record<string, unknown>>;
707
+ readonly edViewPointLocker: string;
708
+ readonly edViewPointClaimer: string;
709
+ readonly context: string;
710
+ readonly viewHalfClaimer: string;
711
+ readonly viewHalfPoint: string;
712
+ /** The one-time Monero address assembled from the recovery key and the answer's points. */
713
+ readonly jointAddress: string;
714
+ /** The escrow address predicted from the signed quote and the depositor's half. */
715
+ readonly escrow: string;
716
+ readonly recovery: { readonly kind: "salt-v2"; readonly salt: string }
717
+ | { readonly kind: "index-v1"; readonly index: number };
718
+ readonly relay: string | null;
719
+ readonly offered: number | null;
720
+ readonly forwarded: Readonly<Record<string, unknown>> | null;
721
+ readonly answer: Readonly<Record<string, unknown>>;
660
722
  }
661
723
 
662
724
  /**
@@ -742,16 +804,21 @@ export interface SwapsApi {
742
804
  /**
743
805
  * Start a swap. The SDK holds the step order, not the screen:
744
806
  * 1) guards (preflight); 2) the swap record (createSwap); 3) the order - it GENERATES the key halves;
745
- * 4) the recovery file / one key and the interface's confirmation that it is saved; 5) locking the funds (signature);
807
+ * 4) the one recovery key and the interface's confirmation that it is saved; 5) locking the funds (signature);
746
808
  * 6) saving on the server; 7) opening the watch on the Monero address.
747
- * HALVES DO NOT EXIST BEFORE THE ORDER: the recovery file carries MY half of the spend key and the counterparty's
748
- * open points, and they are taken from the signed order (www/js/atomic/order-worker.js, createOrderBuilder; in the
749
- * engine they are fetched by halvesWalletOf). So the file is assembled AFTER the order, but BEFORE locking the
750
- * funds.
751
- * FUNDS ARE NOT LOCKED UNTIL onRecoveryFile ALLOWS IT: the order is like this not for convenience, but because a
752
- * lock without a recovery file is a lock with nothing to open it with if the browser is lost.
809
+ * HALVES DO NOT EXIST BEFORE THE ORDER: the key's halves and the counterparty's open points are taken from the
810
+ * signed order. The key is shown BEFORE locking the funds.
811
+ * FUNDS ARE NOT LOCKED UNTIL onRecoveryKey ALLOWS IT: a lock without a saved key is a lock with nothing to open
812
+ * the swap with if the browser is lost.
753
813
  */
754
814
  start(request: StartRequest): Promise<SwapStart>;
815
+ /**
816
+ * START A SELL from a verified relay offer. It records the one-time address, the escrow, our half and the
817
+ * order expectations; it sends nothing. The same offer retried yields the same record (`resumed: true`).
818
+ */
819
+ startSell(request: SellStartRequest): Promise<SellStart>;
820
+ /** CLAIM THE ETH on a sell from the record: the half and the expectations are the ones stored by `startSell`. */
821
+ sellClaim(request: SellClaimRequest, wallet: WalletAdapter): Promise<unknown>;
755
822
  /**
756
823
  * WAIT FOR THE XMR TO ARRIVE at the order's joint address. The polling uses the same request as the page's
757
824
  * (GET /swaps/{id}), the rate and the timeout - from `config.limits` and overridable here.
@@ -768,6 +835,12 @@ export interface SwapsApi {
768
835
  * signed, and the decline is written into the record, so every later read of the swap reports the same state.
769
836
  */
770
837
  watch(request: SwapWatchRequest): Promise<SwapWatch | SwapDeclinedWatch>;
838
+ /**
839
+ * WAIT FOR THE NODE'S SIDE ON A SELL. The escrow is the one predicted by `quotes.firmRelay`; the client sends
840
+ * XMR to the joint address, and this reads the escrow itself until the locker marks it ready, claims it or
841
+ * refunds it. No backend watcher is involved. A wait that expires is `sell-timeout`.
842
+ */
843
+ sellWatch(request: SellWatchRequest): Promise<SellWatchResult>;
771
844
  /**
772
845
  * THE XMR ARRIVAL CHECK WITHOUT THE WATCH LOOP (issue #85). The client reads the chain itself with its own view
773
846
  * key and `ready` opens only when at least two nodes of DIFFERENT operators see the transaction IN THE SAME BLOCK
@@ -1015,6 +1088,53 @@ export interface SwapStart {
1015
1088
  readonly next: "watch";
1016
1089
  }
1017
1090
 
1091
+ export interface SellStartRequest {
1092
+ /** The verified `quotes.firmRelay` answer for the bid side. */
1093
+ readonly offer: FirmRelayAnswer;
1094
+ /** The twelve-word key; required only when no key is stored yet. */
1095
+ readonly recoveryKey?: string | { readonly mnemonic: string };
1096
+ }
1097
+
1098
+ export interface SellStart {
1099
+ readonly id: string;
1100
+ readonly side: "sell";
1101
+ readonly escrow: string;
1102
+ /** The one-time Monero address the client sends XMR to. */
1103
+ readonly address: string;
1104
+ readonly xmrAmount: string;
1105
+ readonly claimTip: string | null;
1106
+ readonly orderQuote: Readonly<Record<string, unknown>> | null;
1107
+ readonly resumed: boolean;
1108
+ readonly next: "send-xmr";
1109
+ }
1110
+
1111
+ export interface SellClaimRequest {
1112
+ readonly id: string;
1113
+ readonly escrow?: string;
1114
+ readonly halfClaimer?: string;
1115
+ readonly expect?: Readonly<Record<string, unknown>>;
1116
+ readonly onStep?: (step: unknown) => void;
1117
+ }
1118
+
1119
+ export interface SellWatchRequest {
1120
+ /** The escrow predicted by the relay adapter. */
1121
+ readonly escrow: string;
1122
+ readonly timeoutMs?: number;
1123
+ readonly pollMs?: number;
1124
+ }
1125
+
1126
+ export interface SellWatchResult {
1127
+ readonly escrow: string;
1128
+ readonly state: "ready" | "claimed" | "refunded";
1129
+ readonly funded: boolean;
1130
+ readonly claimed: boolean;
1131
+ readonly refunded: boolean;
1132
+ /** `null` for a terminal claim/refund: the ready mark is not part of that answer. */
1133
+ readonly ready: boolean | null;
1134
+ readonly balanceWei: string;
1135
+ readonly waitedMs: number;
1136
+ }
1137
+
1018
1138
  export interface SwapWatchRequest {
1019
1139
  /** The watch record's identifier at the service (from SwapStart.watch.id). */
1020
1140
  readonly id: string;
@@ -1123,23 +1243,20 @@ export interface StartRequest {
1123
1243
  /**
1124
1244
  * The EVM wallet for LOCKING THE FUNDS: an adapter with `send` and `receipt` (the same role as WalletAdapter).
1125
1245
  * It comes from outside and is NOT substituted by default: without it the lock step answers with a code
1126
- * (`bad-input`), rather than going in blind. The recovery file has by now been confirmed by the person.
1246
+ * (`bad-input`), rather than going in blind. The recovery key has by now been confirmed by the person.
1127
1247
  */
1128
1248
  readonly wallet?: WalletAdapter;
1129
- /**
1130
- * Hand the OLD per-swap recovery file to the interface and wait for the person's decision.
1131
- * `true` - the person confirmed the file is saved; `false` - the swap is not started.
1132
- * Not used when `recoveryKey` is present: that path has one key, saved once.
1133
- */
1134
- onRecoveryFile?(file: RecoveryFile): Promise<boolean>;
1135
1249
  /**
1136
1250
  * THE ONE RECOVERY KEY (sdk#76). A BIP39 mnemonic, or a key object from `@ninsei-labs/sdk/recovery`.
1137
- * When present, the swap's spend and view halves are derived from it instead of being generated, and no
1138
- * per-swap recovery file is written. The key is kept through the SDK's storage adapter.
1251
+ * The swap's spend and view halves are derived from it; the per-swap recovery file path is gone, and the key is
1252
+ * kept through the SDK's storage adapter.
1139
1253
  */
1140
1254
  recoveryKey?: string | { readonly mnemonic: string };
1141
1255
  /** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
1142
1256
  recoveryIndex?: number;
1257
+ /** The per-order salt the caller put into the signed quote. When present, the key uses the v2 salt binding and
1258
+ * no index is reserved; it must equal `order.salt`. */
1259
+ recoverySalt?: string | number | bigint;
1143
1260
  /** The first block for the on-chain number check. Absent - the chain registry's escrow deployment block, and
1144
1261
  * zero only when the registry has no height. */
1145
1262
  fromBlock?: string | number;
@@ -1272,6 +1389,7 @@ export interface SweepApi {
1272
1389
  */
1273
1390
  wallet(request: SweepWalletRequest): SweepWallet;
1274
1391
 
1392
+ /**
1275
1393
  * COMPARE A DESTINATION WITH THE FILE'S PROMISE. `ok` is "usable address on the file's network", `matches` is
1276
1394
  * "the promised one" and is null when the file named nobody. Sending XMR elsewhere is a different payment, so the
1277
1395
  * comparison is not presentation.
@@ -1344,6 +1462,10 @@ export interface SweepRestoreRequest {
1344
1462
  export interface SweepRestoreResult {
1345
1463
  readonly total: string;
1346
1464
  readonly unlocked: string;
1465
+ /** False when the wallet did not reach the node's height after its sync; a zero balance is then not evidence. */
1466
+ readonly scanComplete: boolean;
1467
+ /** `sync-failed`, `behind-node` or `no-heights` when `scanComplete` is false. */
1468
+ readonly scanIncompleteReason?: string;
1347
1469
  /** The height the scan actually started from. */
1348
1470
  readonly scanHeight: number;
1349
1471
  readonly walletHeight: number | null;
@@ -1431,7 +1553,10 @@ export interface SweepFromOrderRequest {
1431
1553
  readonly edPointLocker: string;
1432
1554
  readonly edPointClaimer: string;
1433
1555
  readonly edViewPointLocker: string;
1434
- readonly index: number;
1556
+ readonly index?: number | null;
1557
+ readonly derivation?: "index-v1" | "salt-v2";
1558
+ readonly factory?: string;
1559
+ readonly salt?: string;
1435
1560
  readonly side: "locker" | "claimer";
1436
1561
  readonly startHeight?: number | null;
1437
1562
  readonly blockTime?: number | null;
@@ -1549,8 +1674,9 @@ export interface SweepSignResult {
1549
1674
  /** The free remainder at the moment of signing, in atomic units (string). */
1550
1675
  readonly amount: string;
1551
1676
  /**
1552
- * THE FEE OF THE SIGNED TRANSFER, in atomic units, or null when the wallet could not report one. A withdrawal
1553
- * moves the whole remainder, so the fee comes out of it - `receive` is what the recipient gets.
1677
+ * THE FEE OF THE SIGNED TRANSFER, in atomic units, summed over every signed transaction, or null when any of
1678
+ * them could not report one. A withdrawal moves the whole remainder, so the fee comes out of it - `receive` is
1679
+ * what the recipient gets.
1554
1680
  */
1555
1681
  readonly fee: string | null;
1556
1682
  /** The remainder minus the fee. Null when the fee is unknown, or when a reported fee claims more than the remainder. */
@@ -1763,6 +1889,8 @@ export type ErrorCode =
1763
1889
  | "server-unavailable"
1764
1890
  /** The service answered and did not accept: the connection got through, the request did not (status and reason in params). */
1765
1891
  | "server-refused"
1892
+ /** The funding transaction was sent, but no status-1 receipt with the expected OrderCreated proves the escrow. */
1893
+ | "funding-pending"
1766
1894
  /** The wait for the XMR to arrive expired: a separate code, because this is a state of the money, not a breakdown. */
1767
1895
  | "xmr-timeout"
1768
1896
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -38,13 +38,39 @@
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:relay-view": "node tools/check-relay-view.mjs",
45
+ "check:relay-terms": "node tools/check-relay-terms.mjs",
46
+ "check:relay-features": "node tools/check-relay-features.mjs",
47
+ "check:relay-request": "node tools/check-relay-request.mjs",
48
+ "check:relay-answer": "node tools/check-relay-answer.mjs",
49
+ "check:relay-http": "node tools/check-relay-http.mjs",
50
+ "check:relay-adapter": "node tools/check-relay-adapter.mjs",
51
+ "check:sell-watch": "node tools/check-sell-watch.mjs",
52
+ "check:sell-start": "node tools/check-sell-start.mjs",
53
+ "check:arrival-consensus": "node tools/check-arrival-consensus.mjs",
54
+ "check:arrival-real": "node tools/check-arrival-real.mjs",
55
+ "check:order-proof": "node tools/check-order-proof.mjs",
41
56
  "check:daemon-address": "node tools/check-daemon-address.mjs",
42
57
  "check:ci-scripts": "node tools/check-ci-scripts.mjs",
43
58
  "check:factory-selectors": "node tools/check-factory-selectors.mjs",
44
59
  "check:fee-estimate": "node tools/check-fee-estimate.mjs",
45
60
  "check:deadline-scale": "node tools/check-deadline-scale.mjs",
46
61
  "check:node-probe": "node tools/check-node-probe.mjs",
47
- "check:recovery-key": "node tools/check.mjs --only=recovery-key"
62
+ "check:recovery-key": "node tools/check.mjs --only=recovery-key",
63
+ "check:recovery-open": "node tools/check-recovery-open.mjs",
64
+ "check:contract-vectors": "node tools/check-contract-vectors.mjs",
65
+ "check:live-contract": "node tools/check-live-contract.mjs",
66
+ "check:anvil-fork": "node tools/check-anvil-fork.mjs",
67
+ "check:dex": "node tools/check.mjs --only=cow,cow-refusal,kyber,kyber-refusal,uniswapx,uniswapx-refusal,uniswap-relay,uniswap-relay-refusal",
68
+ "check:anvil-uniswap": "node tools/check-anvil-uniswap.mjs",
69
+ "check:atomic-flow": "node tools/check-atomic-flow.mjs",
70
+ "check:packed-consumer": "node tools/check-packed-consumer.mjs",
71
+ "check:quote-binding": "node tools/check-quote-binding.mjs",
72
+ "check:types-consumer": "node node_modules/typescript/bin/tsc -p tools/ts-consumer/tsconfig.json",
73
+ "check:monero-live": "node tools/check-monero-live.mjs"
48
74
  },
49
75
  "types": "./index.d.ts",
50
76
  "exports": {
@@ -121,10 +147,16 @@
121
147
  "url": "https://github.com/Ninsei-Labs/sdk/issues"
122
148
  },
123
149
  "dependencies": {
150
+ "@hpke/chacha20poly1305": "1.8.0",
151
+ "@hpke/core": "1.9.0",
152
+ "@hpke/dhkem-x25519": "1.8.0",
124
153
  "@noble/curves": "^2.4.0",
125
154
  "@noble/hashes": "^2.4.0",
126
155
  "@scure/bip39": "2.4.0",
127
156
  "keccak256": "^1.0.6"
128
157
  },
129
- "sideEffects": false
158
+ "sideEffects": false,
159
+ "devDependencies": {
160
+ "typescript": "7.0.2"
161
+ }
130
162
  }
@@ -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.
@@ -269,6 +270,8 @@ export function createNinsei(options) {
269
270
  // A firm quote is stage 2: it is signed with a key and lives in the order protocol, not in a market snapshot.
270
271
  // A FIRM QUOTE: accepted only with a confirmed provider signature.
271
272
  firm: (request) => quotes.firm(request),
273
+ // THE RELAY PATH: one recovery key in, signed orderQuote and its maker view half out.
274
+ firmRelay: (request) => quotes.firmRelay(request),
272
275
  // A REVERSE TICKET: the node signs it; its claim tip is checked here before the caller sees it.
273
276
  sale: (request) => quotes.sale(request),
274
277
  },
@@ -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;