@ninsei-labs/sdk 0.42.1 → 0.43.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,83 @@
1
+ ## 0.43.3 — 2026-10-18
2
+
3
+ THE SAME CONTENT AS 0.43.2, PUBLISHED UNDER A NEW NUMBER. The tag-triggered release for 0.43.2 stopped in
4
+ `pnpm/action-setup` before building anything: the workflow named pnpm 12.10.1 while `packageManager` names
5
+ 12.11.2, and the action refuses two versions (`ERR_PNPM_BAD_PM_VERSION`). Nothing reached the registry. The
6
+ workflow now reads `packageManager` and pins no version of its own; this release carries that fix and the
7
+ 0.43.2 content unchanged.
8
+
9
+ - `./provider` (Node only) and the two bound verification helpers in `./order` are as described in 0.43.2.
10
+ - The display-name draw uses `randomInt`, as fixed in 0.43.2.
11
+ - No package code changed between 0.43.2 and 0.43.3; only the release pipeline did.
12
+
13
+ ## 0.43.2 — 2026-10-18
14
+
15
+ THE PROVIDER SIDE AND THE BOUND VERIFICATION HELPERS ARE PUBLIC. Two things the demo and the node used to reach
16
+ through the node's own files are now package surface: `./order` exports `orderQuoteMissingFields` and
17
+ `recoverOrderQuoteKey` (the spec functions bound to the SDK's primitives), and a new Node-only subpath
18
+ `./provider` carries the provider's identity, signing and env-file helpers. A consumer no longer re-derives the
19
+ provider code rule or the public-key path, and the browser entries stay free of `node:fs` and `node:crypto`.
20
+
21
+ - `./order` gains two names: `orderQuoteMissingFields(quote)` and `recoverOrderQuoteKey(quote)`. Both are the
22
+ spec's own functions bound to `primitives.mjs`, so verification has one implementation, not two.
23
+ - `./provider` is a separate entry because it reaches the filesystem and `node:crypto`: the provider code of an
24
+ address, the public key path beside a private key, key generation, payload signing and verification, and the
25
+ settings-file helpers (`findEnvFile`, `readEnvFile`, `applyEnvFile`, `substituteEnvVars`).
26
+ - The checks gained a `provider` case: the provider code is the address's own name and stable, the public key
27
+ path follows the private one, a settings file is found, read and applied, and the bound quote verification
28
+ names what is missing and recovers nothing from an empty quote.
29
+
30
+ ## 0.43.1 — 2026-10-12
31
+
32
+ THE DECLINE, THE REFUND AND THE MAKER'S RECLAIM EACH CARRY THEIR OWN STATE, AND THE TERMINAL CHECK READS THE
33
+ DECLARATIONS RATHER THAN ONLY THE MAP. A maker's decline is a terminal state of its own, a refund reports
34
+ `refunded_eth` instead of the `closed` fallback, the maker's reclaimed XMR is the phase code `xmr_reclaimed`, and
35
+ `xmr_returned` - a terminal no code path could ever produce - is gone from the public union and from the table.
36
+ Five comments that claimed this package keeps a second, byte-identical copy of the levels walk and of the EIP-712
37
+ spec are corrected: it keeps no such copy.
38
+
39
+ - A MAKER'S DECLINE IS A TERMINAL STATE OF ITS OWN (`declined`). The escrow's `Declined` event and the node's
40
+ `declined` + `compliance` status are two independent sources of the same state and either one is enough; the
41
+ wait for XMR stops at once, the state is written down, and it carries no reason, category or provider at any
42
+ depth. It is told apart from an ordinary refund, which keeps `refunded_eth`.
43
+ - A REFUND REPORTS ITS OWN STATE, NOT THE FALLBACK. The terminal map in src/swaps.mjs was keyed by names no code
44
+ path writes (`refunded` for a refund the engine writes as `kind: "refund_eth"`), so every refund missed its key
45
+ and told the app `closed` about an order whose ETH was already back in the wallet. The map is now keyed by the
46
+ kinds the engine and this package really write - `success`, `refund_eth`, `declined` - exhaustively, and a kind
47
+ nobody knows still comes out as the declared fallback `closed` (`closed` is a fallback, not a key).
48
+ - THE MAKER'S RECLAIMED XMR IS A PHASE CODE OF ITS OWN: `state.phase === "xmr_reclaimed"` (engine/core/swap.js
49
+ `derive`). It was visible only as a label - the shared `refund_available`, which the maker who never locked sets
50
+ too - so an interface had to compare `state.phaseLabel` to tell the two apart. The code is what it branches on
51
+ now; the maker who never locked keeps `refund_available`.
52
+ - `xmr_returned` IS REMOVED from the public `TerminalCode` union (index.d.ts), from the engine's `TERMINAL` table
53
+ and from the code table in src/codes.mjs, with the reason named where each stood. No code path anywhere wrote
54
+ it, and a declared terminal nothing can produce reads as coverage.
55
+ - BREAKING FOR A TYPESCRIPT CONSUMER, ON PURPOSE: a consumer that branched on `xmr_returned` no longer compiles,
56
+ because the member is gone from the union. That is the intent, not a regression - no settlement with that kind
57
+ exists, so no value could ever reach that branch.
58
+ - WHAT A CONSUMER SEES INSTEAD: a swap whose XMR the maker has taken back carries the phase code
59
+ `xmr_reclaimed` with `terminal` still `null` (and not settled) - the depositor's ETH is still in the escrow, so
60
+ the swap has NOT ended; it ends later as `refunded_eth` when the ETH goes back to the wallet.
61
+ - THE TERMINAL CHECK READS THE DECLARATIONS, NOT ONLY THE MAP: `node tools/check.mjs --only=terminal` compared the
62
+ map against the writers, which is exactly how a dead entry outside the map survived. It now reads the engine's
63
+ own `TERMINAL` table and the public `TerminalCode` union as well and asserts BOTH directions against the writers
64
+ - every kind a writer produces has an entry, and every declared terminal is one a writer can produce (or the
65
+ fallback an unknown kind comes out as). A declared-but-unwritable terminal is RED naming the declaration it came
66
+ from. The check also drives the reclaim to its own phase and holds `terminal: null` there.
67
+ - `tools/check-terminal-teeth.mjs` exercises it with EIGHT breaking runs: the dead key put back, the refund's code
68
+ sent to another declared code, a key nothing writes, the decline's own key dropped, a rename in the engine's
69
+ mirror, a terminal with no writer in the engine's table, the same in the public union, and the reclaim's phase
70
+ renamed - each RED, naming its own reason, every file restored byte for byte with sha256 compared.
71
+ - COMMENT-ONLY CORRECTIONS IN FIVE FILES (index.d.ts, src/index.mjs, src/levelsWalkSpec.mjs, src/quoteEip712.mjs,
72
+ src/quoteEip712Spec.mjs): their headers claimed this package exists as two byte-identical copies of the levels
73
+ walk and of the EIP-712 spec, pinned by sha256. Neither is true - only the package has the implementation, a
74
+ node runs it from the entry and is bound to it by the guard on its imports, and the behaviour is pinned by the
75
+ vectors. No assertion, no line of logic, no signature and no export name changed.
76
+
77
+ Checks: `node tools/check.mjs` - all 21 green; `node tools/check-terminal-teeth.mjs` (8 breaking runs),
78
+ `node tools/check-decline-teeth.mjs` (5), `node tools/check-types-teeth.mjs` (4), `node tools/check-refusals-teeth.mjs`
79
+ (5), `node tools/npm-tag.test.mjs` (28 cases) and `node tools/sweep-refusal.test.mjs` (11 checks) are green too.
80
+
1
81
  ## 0.42.1 — 2026-10-12
2
82
 
3
83
  THE BID SIDE OF THE QUOTE WALK ROUNDED THE WRONG WAY. The specification fixes a rounding DIRECTION per side, and
package/README.md CHANGED
@@ -20,7 +20,7 @@ machine). The wallet arrives as an EIP-1193 provider, storage as an adapter, and
20
20
  | `quotes.firm` | answers `not-implemented` (stage 2: signing with a key and the order protocol) |
21
21
  | `swaps.list` / `swaps.get` | read state from storage (and after a page reload) |
22
22
  | `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
- | `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; with expectations and a wallet, places the ready mark after the arrival |
23
+ | `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 |
24
24
  | `wallet.balances` | works: the native coin and tokens, in minimal units |
25
25
 
26
26
  What is not ready names itself with the code `not-implemented` with the fields `surface` and `stage`: an empty
@@ -32,7 +32,7 @@ value that looks like data is more dangerous than a refusal.
32
32
  compares the list of what the SDK returns with what is declared in the contract (both ways).
33
33
 
34
34
  EVERY PUBLISHED SUBPATH CARRIES ITS OWN DECLARATIONS: `.` (`index.d.ts`), `./xmr`, `./order`, `./evm`,
35
- `./core` and `./recovery` (`src/public/<entry>.d.ts`). Each of them declares exactly the symbols its barrel
35
+ `./core`, `./recovery` and `./provider` (`src/public/<entry>.d.ts`). Each of them declares exactly the symbols its barrel
36
36
  re-exports, so a typed consumer sees the same surface the runtime has, and nothing else.
37
37
  `tools/check.mjs --only=types` keeps the two sides equal (a subpath without a `types` target, a declarations
38
38
  file that does not read as declarations, or a symbol declared on one side only is red), and
@@ -74,7 +74,23 @@ user-facing string.
74
74
  | `claimed` | the maker took the ETH |
75
75
  | `swept` | the XMR has been sent to the recipient's address |
76
76
 
77
- Terminal states (`state.terminal`): `success`, `refunded_eth`, `xmr_returned`, `closed`.
77
+ Terminal states (`state.terminal`): `success`, `refunded_eth`, `closed`, `declined`.
78
+
79
+ `xmr_returned` WAS HERE AND IS GONE, AND SO IS THE STATE IT NAMED AS A TERMINAL. When the depositor does not place
80
+ the ready mark in time the maker takes the XMR back — and the swap does NOT end there: the depositor's ETH is still
81
+ in the escrow, so it is a PHASE of a live swap, not a terminal. `state.phase` is then `xmr_reclaimed` (the code an
82
+ interface branches on, while `state.terminal` stays `null`), and the swap ends later as `refunded_eth` when the ETH
83
+ goes back. The code exists because that phase used to be the shared `refund_available`, which the "maker never
84
+ locked the XMR" situation sets too: the two could only be told apart by comparing a label.
85
+
86
+ `declined` — THE MAKER REFUSED A FUNDED ORDER (issue #40). The escrow sets `refunded` and emits
87
+ `Declined(claimer)`, so `status()` alone reads it exactly like any other refund; the SDK tells the two apart by
88
+ that EVENT (the chain, read through `evmLogs`) or by the node's status `declined` with the code `compliance` (the
89
+ wire). Either source stops the wait for XMR at once and ends the swap as `declined` with EVERY action off: the
90
+ whole balance, the fee included, is already back in the wallet and no half was revealed, so there is nothing to
91
+ recover — no recovery file is needed and no manual refund is offered. NO reason, category or provider travels
92
+ with it (the node returns only `compliance`, on purpose), and the neutral sentence a person reads is the
93
+ interface's: this package holds no user-facing string.
78
94
 
79
95
  ### Checks (`preflight().checks[].code`)
80
96
 
@@ -41,10 +41,13 @@ export const STEP_LABELS = [
41
41
  ["swept", "Sent to your address"],
42
42
  ];
43
43
 
44
+ // THE STATES A SWAP ENDS IN. `xmr_returned` STOOD HERE AND IS GONE ON PURPOSE: it was declared with a phase and a
45
+ // label and NO code path anywhere wrote it. The maker taking the XMR back does not END a swap - the depositor's ETH
46
+ // is still in the escrow - so that situation is a PHASE of a live swap (`xmr_reclaimed`, in derive below), and a
47
+ // terminal nothing can produce is worse than no terminal at all: a reader takes the declaration for coverage.
44
48
  export const TERMINAL = {
45
49
  success: { phase: "success", label: "XMR delivered" },
46
50
  refunded_eth: { phase: "refunded_eth", label: "ETH refunded" },
47
- xmr_returned: { phase: "xmr_returned", label: "Swap cancelled, funds returned" },
48
51
  };
49
52
 
50
53
  export function createSwap(input) {
@@ -592,7 +595,12 @@ export function derive(swap, now = nowReal()) {
592
595
  phase = "maker_locking";
593
596
  phaseLabel = "Maker is locking XMR";
594
597
  } else if (xmrReclaimed) {
595
- phase = "refund_available";
598
+ // THE MAKER TOOK THE XMR BACK - A PHASE OF ITS OWN, NOT A TERMINAL AND NOT A STEP. The depositor's ETH is
599
+ // still in the escrow, so the swap is LIVE (`terminal` stays empty) and ends later as `refunded_eth`; and the
600
+ // ordered stepper has no passage for a deal that is taken back. It used to be the shared `refund_available`,
601
+ // which the "maker never locked" situation sets too, so the two could only be told apart by reading the label -
602
+ // `xmr_reclaimed` is the code an interface branches on.
603
+ phase = "xmr_reclaimed";
596
604
  phaseLabel = "XMR returned to the maker";
597
605
  refundAfterSim = makerReclaimAt;
598
606
  can.refund = true;
package/index.d.ts CHANGED
@@ -15,6 +15,25 @@
15
15
  // Naming language: the values are data. `step` and `code` are stable strings from the tables below; they are
16
16
  // translated, shown and coloured as one sees fit. They change only with a package version bump (see CHANGELOG).
17
17
 
18
+ /** One log as a node answers it: an emitter, its topics and its data. Only what the decline is read from is named. */
19
+ export interface EvmLog {
20
+ readonly address?: string;
21
+ readonly topics?: readonly string[];
22
+ readonly data?: string;
23
+ readonly blockNumber?: string;
24
+ readonly transactionHash?: string;
25
+ }
26
+
27
+ /** The log read a caller may bring: the seam the decline is read through. */
28
+ export type EvmLogReader = (
29
+ request: {
30
+ readonly address: string;
31
+ readonly topics?: readonly string[];
32
+ readonly fromBlock?: string | number;
33
+ readonly toBlock?: string | number;
34
+ },
35
+ ) => Promise<readonly EvmLog[]>;
36
+
18
37
  export interface NinseiOptions {
19
38
  /** The service's HTTP API base. Default is "/api" (the interface proxies it to our server). */
20
39
  apiBase?: string;
@@ -80,6 +99,15 @@ export interface NinseiOptions {
80
99
  * matched against the interface's known builds. Same rule as evmCall: a read, not a node substitution.
81
100
  */
82
101
  evmCode?: (request: { readonly address: string }) => Promise<string>;
102
+ /**
103
+ * READING THE ESCROW'S LOGS FROM OUTSIDE (issue #40): `({ address, topics, fromBlock, toBlock }) -> logs`, for
104
+ * the MAKER'S DECLINE. The escrow emits `Declined(claimer)` and keeps NO getter that names it, while `status()`
105
+ * reads a decline and an ordinary refund the same way - so the event is the only on-chain fact that tells them
106
+ * apart. By default the logs are read through the page's wallet, the same provider `evmCall` uses, so the wallet
107
+ * that reads is the wallet that signs. Same rule as `evmCall`: a READ, not a node substitution - the address is
108
+ * the order's escrow and the topic is the package's own.
109
+ */
110
+ evmLogs?: EvmLogReader;
83
111
  /**
84
112
  * THE INTERFACE'S OWN ADMISSION LIST - policy, not proof. The chain (maker registry, providerOf, taken from the
85
113
  * quote) proves whose key signed; this says whom THIS interface trades with at all. An array of provider
@@ -709,8 +737,12 @@ export interface SwapsApi {
709
737
  * IF `expect` AND `wallet` ARE PASSED, then after the arrival the ready mark (actions.markReady) is called with
710
738
  * THESE expectations: the slot check against the chain stands in it before the signature. Without them the step
711
739
  * names the next one (`next: "mark-ready"`) and signs nothing.
740
+ * THE MAKER DECLINED - THE WAIT STOPS AT ONCE (issue #40): the escrow's `Declined` event and the service's
741
+ * `declined` + `compliance` status are BOTH asked on every tick, and either one ends the wait with
742
+ * `SwapDeclinedWatch` instead of running to `xmr-timeout` about an order whose ETH is already back. Nothing is
743
+ * signed, and the decline is written into the record, so every later read of the swap reports the same state.
712
744
  */
713
- watch(request: SwapWatchRequest): Promise<SwapWatch>;
745
+ watch(request: SwapWatchRequest): Promise<SwapWatch | SwapDeclinedWatch>;
714
746
  /**
715
747
  * THE XMR ARRIVAL CHECK WITHOUT THE WATCH LOOP (issue #85). The client reads the chain itself with its own view
716
748
  * key and `ready` opens only when at least two nodes of DIFFERENT operators see the transaction IN THE SAME BLOCK
@@ -985,6 +1017,33 @@ export interface SwapWatchRequest {
985
1017
  readonly readyBySec?: number;
986
1018
  }
987
1019
 
1020
+ /**
1021
+ * THE WATCH'S ANSWER WHEN THE MAKER DECLINED (issue #40). There is no arrival to wait for, so `seen` is false and
1022
+ * there is no `marked`, no `next` and no `txids` - and NO field naming a reason, a category or a provider, because
1023
+ * the node names none. `code` is the node's own single token, unchanged; `terminal` is the state the interface
1024
+ * draws its own neutral sentence from; `source` says WHICH reader saw it (provenance, not a reason).
1025
+ */
1026
+ export interface SwapDeclinedWatch {
1027
+ readonly id: string;
1028
+ /** The escrow the decline was read on, or null when the record did not know it. */
1029
+ readonly escrow: string | null;
1030
+ readonly address: string | null;
1031
+ /** Never true here: a decline is not an arrival. */
1032
+ readonly seen: false;
1033
+ readonly declined: true;
1034
+ readonly terminal: "declined";
1035
+ readonly code: "compliance";
1036
+ /** `event` - the escrow's `Declined` log; `status` - the service's status; `record` - already written down. */
1037
+ readonly source: "event" | "status" | "record";
1038
+ readonly at: number;
1039
+ readonly waitedMs: number;
1040
+ /**
1041
+ * WHAT THE CHAIN READ DID. `checked: false` means the escrow's event could not be read at all (no reader, an
1042
+ * unreachable node) - which is NOT "the escrow declined nothing" - and `error` carries the refusal's own code.
1043
+ */
1044
+ readonly chain: { readonly checked: boolean; readonly error: string | null };
1045
+ }
1046
+
988
1047
  /** THE OUTCOME OF THE WATCH: the arrival is seen as a FACT, not assumed. */
989
1048
  export interface SwapWatch {
990
1049
  readonly seen: true;
@@ -1105,7 +1164,12 @@ export interface SwapState {
1105
1164
  readonly simulated: { readonly evm: boolean; readonly xmr: boolean };
1106
1165
  /** The refusal reason, if any: a code and params, no text. */
1107
1166
  readonly error?: { readonly code: ErrorCode; readonly params?: Readonly<Record<string, unknown>> };
1108
- /** The terminal state, if the swap has ended. */
1167
+ /**
1168
+ * The terminal state, if the swap has ended. `declined` (issue #40) is the maker's refusal of a funded order:
1169
+ * the ETH, the fee included, is already back in the wallet, every flag in `actions` is OFF, and there is nothing
1170
+ * to recover. The interface owes the person a NEUTRAL sentence and no reason, category or provider - the node
1171
+ * names none, and the package forwards none.
1172
+ */
1109
1173
  readonly terminal: TerminalCode | null;
1110
1174
  /**
1111
1175
  * WHEN THE RECORD WAS CREATED (the record's own `createdAt`, an ISO string). null - an old record without
@@ -1114,7 +1178,12 @@ export interface SwapState {
1114
1178
  readonly createdAt: string | null;
1115
1179
  /** WHO GAVE THE PRICE. Either field may be absent: an old record or a swap without a provider. */
1116
1180
  readonly maker: { readonly id: string | null; readonly name: string | null };
1117
- /** The engine's phase code and its human name: the list shows the name. */
1181
+ /**
1182
+ * The engine's phase code and its human name: the list shows the name, the CODE is what an interface branches on.
1183
+ * `xmr_reclaimed` is the maker taking the XMR back before the ready mark - a LIVE state, not a terminal: the
1184
+ * depositor's ETH is still in the escrow, `terminal` stays null and the swap ends later as `refunded_eth`. It is
1185
+ * told apart by that code from the other situation that opens a refund, the maker who never locked the XMR.
1186
+ */
1118
1187
  readonly phase: string | null;
1119
1188
  readonly phaseLabel: string | null;
1120
1189
  /** The swap has a settlement record: the list splits live from settled by THIS flag, not by the phase's text. */
@@ -1141,7 +1210,17 @@ export type StepCode =
1141
1210
  | "claimed"
1142
1211
  | "swept";
1143
1212
 
1144
- export type TerminalCode = "success" | "refunded_eth" | "xmr_returned" | "closed";
1213
+ /**
1214
+ * THE ORDER'S TERMINAL STATE. `declined` is THE MAKER'S REFUSAL OF A FUNDED ORDER (issue #40): the whole balance,
1215
+ * the fee included, is already back in the depositor's wallet and NO half was revealed, so there is nothing to
1216
+ * recover and nothing left to do. It is deliberately NOT `refunded_eth`: that one is the depositor taking the ETH
1217
+ * back, and it DOES reveal the depositor's half. The words a person reads are the interface's, not the package's.
1218
+ *
1219
+ * `xmr_returned` WAS A MEMBER AND IS GONE: it named the maker taking the XMR back, which does NOT end a swap - the
1220
+ * depositor's ETH is still in the escrow - so it is a PHASE of a live swap (`SwapState.phase`, the code
1221
+ * `xmr_reclaimed`), never a terminal. A member with no writer reads as coverage.
1222
+ */
1223
+ export type TerminalCode = "success" | "refunded_eth" | "closed" | "declined";
1145
1224
 
1146
1225
  // --- The XMR withdrawal (the sweep page) --------------------------------------------------------
1147
1226
 
@@ -1300,6 +1379,41 @@ export interface OrderApi {
1300
1379
  * terms hash the recovery file is bound to. The screen uses them to say whether the person is in the dead zone.
1301
1380
  */
1302
1381
  deadlines(escrow: string): Promise<OrderDeadlines>;
1382
+ /**
1383
+ * THE MAKER'S DECLINE, READ FROM THE ESCROW (issue #40): the `Declined` event THE ESCROW EMITTED. It is a
1384
+ * separate read because `status()` cannot answer it - the escrow has no `declined()` getter, and a decline sets
1385
+ * the same `refunded` flag an ordinary refund does. Read through the same seam as the state (`evmLogs`, or the
1386
+ * `logs` argument), so the wallet that reads is the wallet that signs.
1387
+ *
1388
+ * THE ANSWER CARRIES THE STATE AND NOTHING ELSE: no reason, no category, no provider. `checked: false` says the
1389
+ * chain was NOT read, which is not the same as "the escrow did not decline".
1390
+ */
1391
+ decline(escrow: string, deps?: OrderDeclineDeps): Promise<OrderDecline>;
1392
+ }
1393
+
1394
+ /**
1395
+ * WHAT IS KNOWN ABOUT A DECLINE: exactly these fields, and the SHORTNESS IS THE POINT. Anything else would be a
1396
+ * reason, a category or a provider, and the node names none of those on purpose.
1397
+ */
1398
+ export interface OrderDecline {
1399
+ readonly declined: boolean;
1400
+ /** The state the interface draws from (`declined`), or null when this is not a decline. */
1401
+ readonly terminal: "declined" | null;
1402
+ /** The node's own single token, UNCHANGED (`compliance`), or null. It is a token, not a reason. */
1403
+ readonly code: "compliance" | null;
1404
+ /** `event` - the escrow's log was read; `status` - the node's status was read; null - neither. */
1405
+ readonly source: "event" | "status" | null;
1406
+ /** When it was seen (epoch ms), or null. */
1407
+ readonly at: number | null;
1408
+ /** Whether the reading happened at all: `false` is "not measured", NOT "no decline". */
1409
+ readonly checked: boolean;
1410
+ }
1411
+
1412
+ /** The seams of the decline read: the log reader and where the scan starts. */
1413
+ export interface OrderDeclineDeps {
1414
+ readonly logs?: EvmLogReader;
1415
+ /** The first block to scan (the escrow's birth height when it is known). Default: the whole chain. */
1416
+ readonly fromBlock?: string | number;
1303
1417
  }
1304
1418
 
1305
1419
  /** THE ORDER STATE, READ FROM THE CHAIN (escrow status()): three flags and the remainder. */
@@ -1462,7 +1576,7 @@ export declare function createNinsei(options: NinseiOptions): Ninsei;
1462
1576
  /**
1463
1577
  * THE LEVEL WALK. A maker publishes a LEVEL SET - chunks of volume with their own prices - and both the node and
1464
1578
  * the client compute a firm price by WALKING it, before anything is signed. `levelsWalkSpec.mjs` is the ONE
1465
- * implementation, a byte-identical twin of the node's copy (`rfq/lib/levelsWalkSpec.mjs`), and it is exported so a
1579
+ * implementation, taken from the package entry by the node rather than copied, and it is exported so a
1466
1580
  * consumer reaches the same price: the maker's set in, the walk's answer out.
1467
1581
  *
1468
1582
  * NUMBERS ARE INTEGERS, BigInt ONLY. XMR is in atomic units (1 XMR = 1e12); a price is in the asset's atomic units
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.42.1",
3
+ "version": "0.43.3",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
7
7
  },
8
- "packageManager": "pnpm@12.10.1",
8
+ "packageManager": "pnpm@12.11.2",
9
9
  "description": "XMR <-> EVM swap engine: quotes, guards, order steps, recovery (no DOM, no user-facing strings)",
10
10
  "license": "MIT",
11
11
  "type": "module",
@@ -17,12 +17,18 @@
17
17
  "check:surface": "node tools/check.mjs --only=surface",
18
18
  "check:mirror": "node tools/check.mjs --only=mirror",
19
19
  "check:pack": "node tools/check.mjs --only=pack",
20
+ "check:pack-teeth": "node tools/check-pack-teeth.mjs",
20
21
  "check:secrets": "node tools/check.mjs --only=secrets",
21
22
  "check:types": "node tools/check.mjs --only=types",
22
23
  "check:types-teeth": "node tools/check-types-teeth.mjs",
23
24
  "check:refusals": "node tools/check.mjs --only=refusals",
24
25
  "check:refusals-teeth": "node tools/check-refusals-teeth.mjs",
25
- "check:sweep": "node tools/sweep-refusal.test.mjs", "sync:version": "node tools/sync-version.mjs"
26
+ "check:decline": "node tools/check.mjs --only=decline",
27
+ "check:decline-teeth": "node tools/check-decline-teeth.mjs",
28
+ "check:terminal": "node tools/check.mjs --only=terminal",
29
+ "check:terminal-teeth": "node tools/check-terminal-teeth.mjs",
30
+ "check:sweep": "node tools/sweep-refusal.test.mjs",
31
+ "sync:version": "node tools/sync-version.mjs"
26
32
  },
27
33
  "types": "./index.d.ts",
28
34
  "exports": {
@@ -53,6 +59,10 @@
53
59
  "./recovery": {
54
60
  "types": "./src/public/recovery.d.ts",
55
61
  "default": "./src/public/recovery.mjs"
62
+ },
63
+ "./provider": {
64
+ "types": "./src/public/provider.d.ts",
65
+ "default": "./src/public/provider.mjs"
56
66
  }
57
67
  },
58
68
  "files": [
package/src/codes.mjs CHANGED
@@ -13,7 +13,13 @@ export const STEP_CODES = [
13
13
  "swept",
14
14
  ];
15
15
 
16
- export const TERMINAL_CODES = ["success", "refunded_eth", "xmr_returned", "closed"];
16
+ // `declined` IS A TERMINAL STATE OF ITS OWN (issue #40): the maker refused a funded order, so the whole balance -
17
+ // the fee included - is already back in the depositor's wallet and NO half was revealed. It is told apart from
18
+ // `refunded_eth`, which is the depositor taking the ETH back and DOES reveal a half (src/decline.mjs).
19
+ // `xmr_returned` WAS DECLARED HERE AND IS GONE (it had no writer anywhere): the maker's reclaim of the XMR does
20
+ // NOT end a swap - the depositor's ETH is still in the escrow - so it is a PHASE of a live swap (`xmr_reclaimed`,
21
+ // engine/core/swap.js), never a terminal state. A declared terminal nothing can produce reads as coverage.
22
+ export const TERMINAL_CODES = ["success", "refunded_eth", "closed", "declined"];
17
23
 
18
24
  export const CHECK_CODES = [
19
25
  "balance",
@@ -0,0 +1,178 @@
1
+ // THE MAKER'S DECLINE: A STATE OF ITS OWN, TOLD APART FROM AN ORDINARY REFUND.
2
+ //
3
+ // WHAT HAPPENS ON THE CHAIN. A maker who refuses a funded purchase - after screening the deposited ETH, say -
4
+ // calls `decline()` on the escrow. The contract sets `refunded`, emits `Declined(claimer)` and sends the whole
5
+ // balance, the fee included, back to the locker. NO half is revealed: there is nothing to recover, and nobody
6
+ // assembles a Monero key from a declined order.
7
+ //
8
+ // WHY `status()` IS NOT ENOUGH TO SEE IT. `escrow.status()` answers four words - funded, claimed, refunded,
9
+ // balance - and a decline reads exactly like an ordinary refund there: `refunded` is set and the money is back.
10
+ // The two are not the same event, though. An ordinary refund is the depositor (or whoever holds their half)
11
+ // taking the ETH back when the maker did not claim; it REVEALS THE DEPOSITOR'S HALF (`HalfRevealed(half, false)`)
12
+ // so the maker can still take their XMR. A decline is the MAKER closing the order in the depositor's favour and
13
+ // reveals nothing. On screen the two want different things said and different buttons hidden, so the SDK tells
14
+ // them apart instead of reporting "refunded" for both.
15
+ //
16
+ // THE TWO SOURCES, AND EITHER ONE IS ENOUGH:
17
+ // 1) THE ESCROW'S `Declined` EVENT (the chain) - `declinedFromLogs`. The escrow has no getter for a decline
18
+ // (there is no `declined()` view), so the event is the on-chain fact that names it; it is read through the
19
+ // same read seam as every other chain read - an `eth_getLogs` over the escrow's address.
20
+ // 2) THE NODE'S SWAP STATUS `declined` WITH THE CODE `compliance` (the wire) - `declinedFromStatus`. Those ARE
21
+ // the two fields the node returns and nothing else, so BOTH are required: the bare word without its code is
22
+ // not the state the node names, and guessing at it would put a category on a refusal that carries none.
23
+ //
24
+ // WHAT IS DELIBERATELY NOT CARRIED: a reason, a category, a provider. The node returns only `compliance` on
25
+ // purpose - it does not name who declined or why - so the SDK reports the state, the node's own single token and
26
+ // which reader saw it, AND NOTHING ELSE (see DECLINE_FIELDS below). The sentence a person reads is the
27
+ // interface's: this package holds no user-facing string, and a decline is no exception.
28
+ import { SdkError } from "./errors.mjs";
29
+ import { keccak256 } from "./primitives.mjs";
30
+ import * as engine from "./engine.mjs";
31
+
32
+ // THE EVENT, AND ONLY IT. The signature is assembled from its parts and hashed HERE, once, exactly as Solidity
33
+ // does it: keccak256("Declined(address)"). The topic is exported so a caller filters logs with it rather than
34
+ // deriving it a second time - a second copy of the same event would be a second truth about it.
35
+ export const DECLINE_EVENT_NAME = "Declined";
36
+ export const DECLINE_EVENT_SIGNATURE = () => `${DECLINE_EVENT_NAME}(${["address"].join(",")})`;
37
+
38
+ const hexOf = (bytes) => {
39
+ let out = "0x";
40
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
41
+ return out;
42
+ };
43
+
44
+ /** keccak256("Declined(address)") - the first (and only) topic of a decline's log. */
45
+ export const DECLINED_TOPIC0 = hexOf(keccak256(DECLINE_EVENT_SIGNATURE()));
46
+
47
+ // THE NODE'S OWN TWO FIELDS for a declined swap: `{ status: "declined", code: "compliance" }`. The code is the
48
+ // node's single token, deliberately opaque: it says "a screening decision was taken", not by whom or on what.
49
+ export const DECLINED_STATUS = "declined";
50
+ export const DECLINED_CODE = "compliance";
51
+
52
+ // THE STATE'S OWN CODE - a `TerminalCode` (`index.d.ts`). The interface draws the decline screen from THIS, and
53
+ // phrases the sentence itself; the package contributes the state and the code, not the words.
54
+ export const DECLINED_TERMINAL = "declined";
55
+
56
+ /**
57
+ * THE WHOLE SET OF FIELDS A DECLINE IS REPORTED WITH - and the list is short ON PURPOSE. Everything else would be
58
+ * a reason, a category or a provider, and the node names none of those:
59
+ * `declined` - what happened (true/false);
60
+ * `terminal` - the state the interface draws from (`declined`, or null);
61
+ * `code` - the node's own token, UNCHANGED, or null;
62
+ * `source` - which reader saw it: `event` (the escrow) or `status` (the node). PROVENANCE, NOT A REASON:
63
+ * it says where the SDK learned it, not why the maker refused;
64
+ * `at` - when it was seen (epoch ms), or null;
65
+ * `checked` - whether the chain was read at all. `false` means "not measured", which is NOT "no decline" - a
66
+ * caller that blurs the two would read a maker's refusal as an ordinary refund.
67
+ */
68
+ export const DECLINE_FIELDS = ["declined", "terminal", "code", "source", "at", "checked"];
69
+
70
+ // Every verdict is built through this, so no field can creep in beside the list above: the reporter is the shape.
71
+ const verdictOf = (fields) => {
72
+ const out = {};
73
+ for (const key of DECLINE_FIELDS) out[key] = fields[key] === undefined ? null : fields[key];
74
+ return out;
75
+ };
76
+
77
+ /** A DECLINE, AS THE SDK REPORTS IT: the state, the node's token and where it was seen - and nothing more. */
78
+ export const declineSeen = ({ source = null, at = null } = {}) =>
79
+ verdictOf({ declined: true, terminal: DECLINED_TERMINAL, code: DECLINED_CODE, source, at, checked: true });
80
+
81
+ /**
82
+ * NO DECLINE. `checked: false` says the reading did not happen (no chain reader, no status body): the two are
83
+ * kept apart on purpose, because "we could not look" and "we looked and it was not a decline" are different
84
+ * states of knowledge, and only one of them lets a caller treat a closed escrow as an ordinary refund.
85
+ */
86
+ export const declineNone = ({ source = null, at = null, checked = true } = {}) =>
87
+ verdictOf({ declined: false, terminal: null, code: null, source, at, checked });
88
+
89
+ /**
90
+ * SOURCE 1 - THE ESCROW'S `Declined` EVENT. `logs` is what the log read answered: an ARRAY of log objects.
91
+ * Anything that is not an array is a refusal (`bad-input`), never "no decline": an unread chain must not be
92
+ * reported as an escrow that declined nothing.
93
+ *
94
+ * Only the FIRST TOPIC is compared (the event's own signature). A log from another address is skipped when the
95
+ * answer carries one and the caller named the escrow; a log without an address is still matched, because the
96
+ * request that produced it was filtered by address and dropping it would hide a real decline.
97
+ */
98
+ export function declinedFromLogs(logs, { escrow = null, at = null } = {}) {
99
+ if (!Array.isArray(logs)) throw new SdkError("bad-input", { field: "logs", why: "not-array" });
100
+ const wanted = typeof escrow === "string" && escrow ? escrow.toLowerCase() : null;
101
+ for (const log of logs) {
102
+ if (!log || typeof log !== "object") continue;
103
+ if (wanted && typeof log.address === "string" && log.address && log.address.toLowerCase() !== wanted) continue;
104
+ const topics = Array.isArray(log.topics) ? log.topics : [];
105
+ if (String(topics[0] || "").toLowerCase() === DECLINED_TOPIC0) return declineSeen({ source: "event", at });
106
+ }
107
+ return declineNone({ source: "event", at });
108
+ }
109
+
110
+ /**
111
+ * SOURCE 2 - THE NODE'S SWAP STATUS. `{ status: "declined", code: "compliance" }`, in either of the two shapes
112
+ * the wire carries it in: at the top level of the answer (the swap state a backend relays) or NESTED UNDER
113
+ * `userStatus` (the field the maker API names it by). BOTH fields must be there and must be exactly these tokens;
114
+ * anything else is not the state the node names, and a partial match is NOT a decline.
115
+ *
116
+ * A body that is not an object at all answers `checked: false` - there was nothing to read, which is not the same
117
+ * as having read it and found no decline.
118
+ */
119
+ export function declinedFromStatus(body, { at = null } = {}) {
120
+ if (!body || typeof body !== "object") return declineNone({ source: "status", at, checked: false });
121
+ for (const candidate of [body, body.userStatus]) {
122
+ if (!candidate || typeof candidate !== "object") continue;
123
+ if (String(candidate.status || "") === DECLINED_STATUS && String(candidate.code || "") === DECLINED_CODE) {
124
+ return declineSeen({ source: "status", at });
125
+ }
126
+ }
127
+ return declineNone({ source: "status", at });
128
+ }
129
+
130
+ /**
131
+ * BOTH SOURCES AT ONCE, AND EITHER ONE IS ENOUGH - the form the flow uses. The chain is asked first (the escrow's
132
+ * own event is the stronger fact) and the wire second; a source that was not read is named as such (its own
133
+ * verdict stays, with `source: null` when neither was read), and a body without the node's two fields does not
134
+ * cancel a chain answer.
135
+ */
136
+ export function declineOf({ logs = undefined, status = undefined, escrow = null, at = null } = {}) {
137
+ const fromChain = Array.isArray(logs) ? declinedFromLogs(logs, { escrow, at }) : null;
138
+ if (fromChain && fromChain.declined) return fromChain;
139
+ const fromWire = status && typeof status === "object" ? declinedFromStatus(status, { at }) : null;
140
+ if (fromWire && fromWire.declined) return fromWire;
141
+ if (fromChain) return fromChain; // the chain was read: it answered "not declined"
142
+ if (fromWire) return fromWire; // only the wire was read, and it was not a decline
143
+ return declineNone({ checked: false, at }); // nothing was read, which is NOT "no decline"
144
+ }
145
+
146
+ /**
147
+ * THE DEFAULT CHAIN LOG READER: `eth_getLogs` THROUGH THE PAGE'S WALLET - the same provider the engine's
148
+ * `readContract` uses, so the wallet that reads is the wallet that will sign (the rule the whole chain-read seam
149
+ * follows). It is a READ, not a node substitution: the address is the caller's escrow and the filter is the
150
+ * decline's own topic.
151
+ *
152
+ * `fromBlock` is the caller's: a watch that knows the escrow's birth height scans from there instead of walking
153
+ * the chain's whole history. An answer that is not an array is a REFUSAL - a node that answered something else
154
+ * did not answer "no decline", and a caller must not read it as one.
155
+ */
156
+ export async function readDeclinedLogs({ address, topics = [DECLINED_TOPIC0], fromBlock = "earliest", toBlock = "latest" } = {}) {
157
+ if (typeof address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(address)) throw new SdkError("bad-input", { field: "logs.address" });
158
+ let provider = null;
159
+ try { provider = typeof engine.evm.currentProvider === "function" ? engine.evm.currentProvider() : null; } catch { provider = null; }
160
+ if (!provider || typeof provider.request !== "function") throw new SdkError("bad-input", { field: "evmLogs", why: "no-provider" });
161
+ const answer = await provider.request({ method: "eth_getLogs", params: [{ address, topics, fromBlock, toBlock }] });
162
+ if (!Array.isArray(answer)) throw new SdkError("server-unavailable", { step: "logs", why: "bad-response" });
163
+ return answer;
164
+ }
165
+
166
+ /** The log reader a call was given: the caller's seam, or the default above. */
167
+ export const logReaderOf = (logs) => (typeof logs === "function" ? logs : readDeclinedLogs);
168
+
169
+ /**
170
+ * A BLOCK BOUNDARY AS THE NODE WANTS IT (hex quantity), for a caller that knows where its escrow was born.
171
+ * A value that is not a whole, non-negative number is the fallback - the safe end of the chain, not an invented
172
+ * block: scanning too far is slow, scanning too little misses the event.
173
+ */
174
+ export const blockBoundaryOf = (height, fallback = "earliest") => {
175
+ const value = Number(height);
176
+ if (!Number.isFinite(value) || value < 0) return fallback;
177
+ return "0x" + Math.trunc(value).toString(16);
178
+ };