@ninsei-labs/sdk 0.42.0 → 0.43.1

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,70 @@
1
+ ## 0.43.1 — 2026-10-12
2
+
3
+ THE DECLINE, THE REFUND AND THE MAKER'S RECLAIM EACH CARRY THEIR OWN STATE, AND THE TERMINAL CHECK READS THE
4
+ DECLARATIONS RATHER THAN ONLY THE MAP. A maker's decline is a terminal state of its own, a refund reports
5
+ `refunded_eth` instead of the `closed` fallback, the maker's reclaimed XMR is the phase code `xmr_reclaimed`, and
6
+ `xmr_returned` - a terminal no code path could ever produce - is gone from the public union and from the table.
7
+ Five comments that claimed this package keeps a second, byte-identical copy of the levels walk and of the EIP-712
8
+ spec are corrected: it keeps no such copy.
9
+
10
+ - A MAKER'S DECLINE IS A TERMINAL STATE OF ITS OWN (`declined`). The escrow's `Declined` event and the node's
11
+ `declined` + `compliance` status are two independent sources of the same state and either one is enough; the
12
+ wait for XMR stops at once, the state is written down, and it carries no reason, category or provider at any
13
+ depth. It is told apart from an ordinary refund, which keeps `refunded_eth`.
14
+ - A REFUND REPORTS ITS OWN STATE, NOT THE FALLBACK. The terminal map in src/swaps.mjs was keyed by names no code
15
+ path writes (`refunded` for a refund the engine writes as `kind: "refund_eth"`), so every refund missed its key
16
+ and told the app `closed` about an order whose ETH was already back in the wallet. The map is now keyed by the
17
+ kinds the engine and this package really write - `success`, `refund_eth`, `declined` - exhaustively, and a kind
18
+ nobody knows still comes out as the declared fallback `closed` (`closed` is a fallback, not a key).
19
+ - THE MAKER'S RECLAIMED XMR IS A PHASE CODE OF ITS OWN: `state.phase === "xmr_reclaimed"` (engine/core/swap.js
20
+ `derive`). It was visible only as a label - the shared `refund_available`, which the maker who never locked sets
21
+ too - so an interface had to compare `state.phaseLabel` to tell the two apart. The code is what it branches on
22
+ now; the maker who never locked keeps `refund_available`.
23
+ - `xmr_returned` IS REMOVED from the public `TerminalCode` union (index.d.ts), from the engine's `TERMINAL` table
24
+ and from the code table in src/codes.mjs, with the reason named where each stood. No code path anywhere wrote
25
+ it, and a declared terminal nothing can produce reads as coverage.
26
+ - BREAKING FOR A TYPESCRIPT CONSUMER, ON PURPOSE: a consumer that branched on `xmr_returned` no longer compiles,
27
+ because the member is gone from the union. That is the intent, not a regression - no settlement with that kind
28
+ exists, so no value could ever reach that branch.
29
+ - WHAT A CONSUMER SEES INSTEAD: a swap whose XMR the maker has taken back carries the phase code
30
+ `xmr_reclaimed` with `terminal` still `null` (and not settled) - the depositor's ETH is still in the escrow, so
31
+ the swap has NOT ended; it ends later as `refunded_eth` when the ETH goes back to the wallet.
32
+ - THE TERMINAL CHECK READS THE DECLARATIONS, NOT ONLY THE MAP: `node tools/check.mjs --only=terminal` compared the
33
+ map against the writers, which is exactly how a dead entry outside the map survived. It now reads the engine's
34
+ own `TERMINAL` table and the public `TerminalCode` union as well and asserts BOTH directions against the writers
35
+ - every kind a writer produces has an entry, and every declared terminal is one a writer can produce (or the
36
+ fallback an unknown kind comes out as). A declared-but-unwritable terminal is RED naming the declaration it came
37
+ from. The check also drives the reclaim to its own phase and holds `terminal: null` there.
38
+ - `tools/check-terminal-teeth.mjs` exercises it with EIGHT breaking runs: the dead key put back, the refund's code
39
+ sent to another declared code, a key nothing writes, the decline's own key dropped, a rename in the engine's
40
+ mirror, a terminal with no writer in the engine's table, the same in the public union, and the reclaim's phase
41
+ renamed - each RED, naming its own reason, every file restored byte for byte with sha256 compared.
42
+ - COMMENT-ONLY CORRECTIONS IN FIVE FILES (index.d.ts, src/index.mjs, src/levelsWalkSpec.mjs, src/quoteEip712.mjs,
43
+ src/quoteEip712Spec.mjs): their headers claimed this package exists as two byte-identical copies of the levels
44
+ walk and of the EIP-712 spec, pinned by sha256. Neither is true - only the package has the implementation, a
45
+ node runs it from the entry and is bound to it by the guard on its imports, and the behaviour is pinned by the
46
+ vectors. No assertion, no line of logic, no signature and no export name changed.
47
+
48
+ Checks: `node tools/check.mjs` - all 21 green; `node tools/check-terminal-teeth.mjs` (8 breaking runs),
49
+ `node tools/check-decline-teeth.mjs` (5), `node tools/check-types-teeth.mjs` (4), `node tools/check-refusals-teeth.mjs`
50
+ (5), `node tools/npm-tag.test.mjs` (28 cases) and `node tools/sweep-refusal.test.mjs` (11 checks) are green too.
51
+
52
+ ## 0.42.1 — 2026-10-12
53
+
54
+ THE BID SIDE OF THE QUOTE WALK ROUNDED THE WRONG WAY. The specification fixes a rounding DIRECTION per side, and
55
+ the walk rounded both sides alike: a bid price came out up to 3 wei high and disagreed with the reference at a
56
+ level boundary.
57
+
58
+ - Per-level rounding follows the side now: a level's cost rounds up on the ask side, where the user pays, and down
59
+ on the bid side, where the user is paid; the XMR amount a budget yields at a level rounds up on the bid side.
60
+ - The level-fits test is asymmetric: a level is taken whole when `remaining >= full` on the ask side and when
61
+ `remaining > full` on the bid side.
62
+ - This removes a systematic 1-to-3-wei over-charge on bid prices and the disagreement with the reference at level
63
+ boundaries.
64
+
65
+ Checks: `node tools/check.mjs` - all 19 green; `node tools/sync-version.mjs`, `node tools/npm-tag.test.mjs`, the two
66
+ teeth runs and `tools/sweep-refusal.test.mjs` are green too.
67
+
1
68
  ## 0.42.0 — 2026-10-12
2
69
 
3
70
  EVERY ENTRY THIS PACKAGE PUBLISHES CARRIES ITS OWN DECLARATIONS, AND THE SWEEP PAGE'S TWO PRIMITIVES ARE ON
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
@@ -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
@@ -500,7 +528,8 @@ export interface QuoteOffer {
500
528
  * The providers already name them in their own tables (`COW_REFUSAL_CODES` and its three siblings), and the
501
529
  * battery proves each of those is reachable; what was missing was the TYPE, so an interface could enumerate
502
530
  * them instead of meeting a string it cannot switch on. `broken` is shared by all four: it is the code for a
503
- * case a provider could not classify.
531
+ * case a provider could not classify. `RouteRefusalTables` below declares what those tables ARE - each of them a
532
+ * `readonly RouteRefusalCode[]` - so the union is the tables' TYPE and not a comment standing beside them.
504
533
  *
505
534
  * These are OUR codes, and they are what a person's message should be phrased from. A provider's OWN reason
506
535
  * stays opaque on purpose (`QuoteRefusal.why`): that is data the provider sent, not a name of ours, so it is
@@ -546,6 +575,28 @@ export type RouteRefusalCode =
546
575
  // shared
547
576
  | "broken";
548
577
 
578
+ /**
579
+ * WHAT THE FOUR REFUSAL TABLES *ARE*. Each route provider names its own refusals in one table - `COW_REFUSAL_CODES`
580
+ * (src/legs/cow.mjs), `KYBER_REFUSAL_CODES` (src/legs/kyber.mjs), `UNISWAPX_REFUSAL_CODES` (src/legs/uniswapx.mjs) and
581
+ * `UNISWAPRELAY_REFUSAL_CODES` (src/legs/uniswaprelay.mjs) - and every one of those tables is a
582
+ * `readonly RouteRefusalCode[]`. The dependency runs ONE WAY on purpose: a table answers to the union (a code that is
583
+ * not declared above cannot enter a table), while the union answers to no single table, so the same vocabulary is
584
+ * met in four modules.
585
+ *
586
+ * THEY ARE NOT ON THIS ENTRY'S SURFACE, and this is deliberately NOT a value: the package re-exports no provider and
587
+ * no table (src/index.mjs), the tables travel in the shipped source (`files` in package.json) and they are the
588
+ * VOCABULARY, not an API. A `declare const` here that the runtime does not export would be a lie, and the `surface`
589
+ * check refuses exactly that - so the tables are declared as their TYPE. That is what keeps the union from being a
590
+ * comment beside the tables: this declaration says what they are, and the `refusals` check says the same thing from
591
+ * the other side - a table declared here as a plain `readonly string[]` is a named problem there.
592
+ */
593
+ export interface RouteRefusalTables {
594
+ readonly COW_REFUSAL_CODES: readonly RouteRefusalCode[];
595
+ readonly KYBER_REFUSAL_CODES: readonly RouteRefusalCode[];
596
+ readonly UNISWAPX_REFUSAL_CODES: readonly RouteRefusalCode[];
597
+ readonly UNISWAPRELAY_REFUSAL_CODES: readonly RouteRefusalCode[];
598
+ }
599
+
549
600
  export interface QuoteRefusal {
550
601
  readonly providerId: string;
551
602
  readonly displayName: string | null;
@@ -686,8 +737,12 @@ export interface SwapsApi {
686
737
  * IF `expect` AND `wallet` ARE PASSED, then after the arrival the ready mark (actions.markReady) is called with
687
738
  * THESE expectations: the slot check against the chain stands in it before the signature. Without them the step
688
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.
689
744
  */
690
- watch(request: SwapWatchRequest): Promise<SwapWatch>;
745
+ watch(request: SwapWatchRequest): Promise<SwapWatch | SwapDeclinedWatch>;
691
746
  /**
692
747
  * THE XMR ARRIVAL CHECK WITHOUT THE WATCH LOOP (issue #85). The client reads the chain itself with its own view
693
748
  * key and `ready` opens only when at least two nodes of DIFFERENT operators see the transaction IN THE SAME BLOCK
@@ -962,6 +1017,33 @@ export interface SwapWatchRequest {
962
1017
  readonly readyBySec?: number;
963
1018
  }
964
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
+
965
1047
  /** THE OUTCOME OF THE WATCH: the arrival is seen as a FACT, not assumed. */
966
1048
  export interface SwapWatch {
967
1049
  readonly seen: true;
@@ -1082,7 +1164,12 @@ export interface SwapState {
1082
1164
  readonly simulated: { readonly evm: boolean; readonly xmr: boolean };
1083
1165
  /** The refusal reason, if any: a code and params, no text. */
1084
1166
  readonly error?: { readonly code: ErrorCode; readonly params?: Readonly<Record<string, unknown>> };
1085
- /** 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
+ */
1086
1173
  readonly terminal: TerminalCode | null;
1087
1174
  /**
1088
1175
  * WHEN THE RECORD WAS CREATED (the record's own `createdAt`, an ISO string). null - an old record without
@@ -1091,7 +1178,12 @@ export interface SwapState {
1091
1178
  readonly createdAt: string | null;
1092
1179
  /** WHO GAVE THE PRICE. Either field may be absent: an old record or a swap without a provider. */
1093
1180
  readonly maker: { readonly id: string | null; readonly name: string | null };
1094
- /** 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
+ */
1095
1187
  readonly phase: string | null;
1096
1188
  readonly phaseLabel: string | null;
1097
1189
  /** The swap has a settlement record: the list splits live from settled by THIS flag, not by the phase's text. */
@@ -1118,7 +1210,17 @@ export type StepCode =
1118
1210
  | "claimed"
1119
1211
  | "swept";
1120
1212
 
1121
- 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";
1122
1224
 
1123
1225
  // --- The XMR withdrawal (the sweep page) --------------------------------------------------------
1124
1226
 
@@ -1277,6 +1379,41 @@ export interface OrderApi {
1277
1379
  * terms hash the recovery file is bound to. The screen uses them to say whether the person is in the dead zone.
1278
1380
  */
1279
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;
1280
1417
  }
1281
1418
 
1282
1419
  /** THE ORDER STATE, READ FROM THE CHAIN (escrow status()): three flags and the remainder. */
@@ -1439,7 +1576,7 @@ export declare function createNinsei(options: NinseiOptions): Ninsei;
1439
1576
  /**
1440
1577
  * THE LEVEL WALK. A maker publishes a LEVEL SET - chunks of volume with their own prices - and both the node and
1441
1578
  * the client compute a firm price by WALKING it, before anything is signed. `levelsWalkSpec.mjs` is the ONE
1442
- * 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
1443
1580
  * consumer reaches the same price: the maker's set in, the walk's answer out.
1444
1581
  *
1445
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.42.0",
3
+ "version": "0.43.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -20,6 +20,12 @@
20
20
  "check:secrets": "node tools/check.mjs --only=secrets",
21
21
  "check:types": "node tools/check.mjs --only=types",
22
22
  "check:types-teeth": "node tools/check-types-teeth.mjs",
23
+ "check:refusals": "node tools/check.mjs --only=refusals",
24
+ "check:refusals-teeth": "node tools/check-refusals-teeth.mjs",
25
+ "check:decline": "node tools/check.mjs --only=decline",
26
+ "check:decline-teeth": "node tools/check-decline-teeth.mjs",
27
+ "check:terminal": "node tools/check.mjs --only=terminal",
28
+ "check:terminal-teeth": "node tools/check-terminal-teeth.mjs",
23
29
  "check:sweep": "node tools/sweep-refusal.test.mjs", "sync:version": "node tools/sync-version.mjs"
24
30
  },
25
31
  "types": "./index.d.ts",
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
+ };
package/src/index.mjs CHANGED
@@ -85,6 +85,12 @@ const normalizeOptions = (options) => {
85
85
  // THE CODE-READ SEAM (issue #103): ({address}) -> hex code, for the extcodehash check of the factory. Same
86
86
  // rule as evmCall - a read brought from outside, not a substitution of the chain.
87
87
  if (o.evmCode !== undefined && typeof o.evmCode !== "function") fail("bad-input", { field: "evmCode" });
88
+ // THE LOG-READ SEAM (issue #40): ({ address, topics, fromBlock, toBlock }) -> logs. Needed for the maker's
89
+ // decline, which is the escrow's `Declined` EVENT - the escrow has no getter that names it, and `status()` reads
90
+ // a decline and an ordinary refund the same way. By default the logs are read through the page's wallet, the
91
+ // same provider the chain read uses. Same rule as evmCall: a READ of the escrow's own event, not a node
92
+ // substitution.
93
+ if (o.evmLogs !== undefined && typeof o.evmLogs !== "function") fail("bad-input", { field: "evmLogs" });
88
94
  // THE INTERFACE'S ALLOW-LIST (POLICY, NOT PROOF): an array of provider addresses (or providerIds) OR a
89
95
  // predicate. It is NOT the check itself - that is `providerOf` on chain, taken from the quote - but the
90
96
  // interface's own decision about whom it trades with. A wrong shape is a typo, not a silent no-op.
@@ -149,6 +155,7 @@ const normalizeOptions = (options) => {
149
155
  addNodes: o.addNodes === undefined ? null : o.addNodes,
150
156
  evmCall: o.evmCall || null,
151
157
  evmCode: o.evmCode || null,
158
+ evmLogs: o.evmLogs || null,
152
159
  allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
153
160
  knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
154
161
  knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
@@ -216,6 +223,10 @@ export function createNinsei(options) {
216
223
  // THE SAME CHAIN-READ SEAM FOR THE ORDER READS: state and deadlines must be read by the wallet that will sign,
217
224
  // not by a second provider. Without a caller reader the module keeps its engine default.
218
225
  const orderDeps = typeof o.evmCall === "function" ? { call: o.evmCall } : {};
226
+ // THE LOG SEAM GOES WITH THE ORDER READS: the decline is read by the same caller-brought reader as the state,
227
+ // for the same reason (the wallet that reads must be the wallet that signs), and it is the READER that is
228
+ // substituted - the addresses and the topic stay the core's.
229
+ const logDeps = typeof o.evmLogs === "function" ? { logs: o.evmLogs } : {};
219
230
 
220
231
  return Object.freeze({
221
232
  config,
@@ -238,6 +249,11 @@ export function createNinsei(options) {
238
249
  // THE ORDER DEADLINES (t1, readyBy, termsHash) from the chain: the screen needs them to say whether the
239
250
  // person is in the dead zone.
240
251
  deadlines: (escrow) => orderFlow.orderDeadlines(escrow, orderDeps),
252
+ // THE MAKER'S DECLINE, READ FROM THE ESCROW (issue #40): the `Declined` event, which is the on-chain fact
253
+ // that tells a decline apart from an ordinary refund. The answer carries the state and the node's own token
254
+ // and NOTHING ELSE - no reason, no category, no provider. `checked: false` means the chain was not read,
255
+ // which is NOT the same as "the escrow did not decline".
256
+ decline: (escrow, deps = {}) => orderFlow.orderDecline(escrow, { ...logDeps, ...(deps || {}) }),
241
257
  },
242
258
  sim,
243
259
  quotes: {
@@ -260,6 +276,9 @@ export function createNinsei(options) {
260
276
  // would not know where to knock, and without the pass there is nothing to present to the service (and that
261
277
  // is a refusal with a CODE, not an invented path).
262
278
  swaps: createSwaps({ preflight, http, mode: o.mode, xmrNetwork: o.xmrNetwork, call: o.evmCall,
279
+ // THE LOG SEAM FOR THE DECLINE CHECK: the watch asks the escrow for its `Declined` event on every tick, so
280
+ // a declined order stops the wait at once instead of running to `xmr-timeout`.
281
+ logs: o.evmLogs || null,
263
282
  config, auth: o.serverAuth, denylist, watch: o.watch,
264
283
  // THE RECORD IS BUILT HERE ITSELF, and one brought from outside is only the caller's preference.
265
284
  createRecord: o.createRecord }),
@@ -274,7 +293,7 @@ export { SDK_VERSION } from "./version.mjs";
274
293
 
275
294
  // THE LEVEL WALK IS ON THE PACKAGE SURFACE. A maker publishes a level set (chunks of volume with their own prices)
276
295
  // and both the node and the client compute a firm price by WALKING it - the client must reach the SAME price before
277
- // signing. `levelsWalkSpec.mjs` is the ONE implementation, a byte-identical twin of the node's copy, and it is
278
- // exported so a consumer can run the same walk: the set in, the answer out. Answers are values with a `reason`
279
- // token on refusal, never exceptions.
296
+ // signing. `levelsWalkSpec.mjs` is the ONE implementation, which the node takes from the package entry rather than
297
+ // copying, and it is exported so a consumer can run the same walk: the set in, the answer out. Answers are values
298
+ // with a `reason` token on refusal, never exceptions.
280
299
  export { checkLevels, xmrForAsset, assetForXmr, toBigInt, XMR_ATOMIC_PER_ONE } from "./levelsWalkSpec.mjs";
@@ -1,13 +1,13 @@
1
1
  // THE MAKER'S LEVEL SET: THE REFERENCE WALK OVER IT (issue #110, part B).
2
2
  //
3
3
  // WHY. The maker sends the node a LEVEL SET - chunks of volume with their own prices - and the node computes a
4
- // firm price by WALKING it. The client (SDK) needs the same walk to show the same price before signing. This is
5
- // the SINGLE implementation of the walk and of the level-set rules; the file exists as BYTE-IDENTICAL copies in
6
- // two places, and tools/check-levels-walk.mjs verifies that:
7
- // node: rfq/lib/levelsWalkSpec.mjs SDK: sdk/src/levelsWalkSpec.mjs
8
- // Two copies, not an import: the node runs on the provider's machine and sees neither www/ nor sdk/, and the SDK
9
- // package does not see rfq/ - each starts on its own (the same argument as rfq/lib/quoteEip712Spec.mjs). A
10
- // divergence would mean the node and the client name a DIFFERENT price for the same set.
4
+ // firm price by WALKING it. The client (SDK) needs the same walk to show the same price before signing. This file
5
+ // is the SINGLE implementation of the walk and of the level-set rules, and BOTH sides run it from here: the node
6
+ // imports the package entry, so it keeps no copy of its own. A second copy would let the node and the client name
7
+ // a DIFFERENT price for the same set.
8
+ // WHAT HOLDS IT: the node is bound to the package by the guard on its imports (no copy may come back under
9
+ // rfq/lib), and the behaviour of this one implementation is pinned by the vectors in tools/check-levels-walk.mjs,
10
+ // computed independently of this code - not by comparing two files.
11
11
  //
12
12
  // NUMBERS ARE INTEGERS ONLY, BigInt ONLY (rule 10). XMR is in atomic units (1 XMR = 1e12). A price is in the
13
13
  // asset's atomic units PER 1 WHOLE XMR (for a wei asset that is wei per 1 XMR). No Number for money.
@@ -19,7 +19,9 @@
19
19
  // 3. the first level sets the minimum deal size (there is no separate min);
20
20
  // 4. an EMPTY set = the side is off, and that is NOT an error;
21
21
  // 5. the walk goes BOTH ways: "I give asset - how much XMR" and "I give XMR - how much asset";
22
- // 6. rounding to atomic units is in the maker's favour;
22
+ // 6. rounding to atomic units is in the maker's favour, EACH LEVEL ON ITS OWN: up on ask, where the user pays,
23
+ // down on bid, where the user is paid; the answer in XMR is rounded the maker's way too, down for a buyer
24
+ // (ask) and up for a seller (bid);
23
25
  // 7. one orientation: volume in XMR, price in the asset per 1 XMR;
24
26
  // 8. a fixed swap fee (fee in wei, separate for each side): the buyer pays the walk PLUS the fee, the seller
25
27
  // receives the walk MINUS the fee. It is NOT spread over the volume: otherwise the average price would fall
@@ -83,51 +85,65 @@ export function checkLevels(levels, orientation = "ask") {
83
85
  return { ok: true, enabled: true, count: levels.length, minAtomic: toBigInt(levels[0].amount), totalAtomic };
84
86
  }
85
87
 
86
- // THE COST OF A CHUNK OF VOLUME IN WEI, ROUNDED UP (rule 6, in the maker's favour).
87
- function levelCostWei(amountAtomic, priceWeiPerXmr) {
88
- const num = amountAtomic * priceWeiPerXmr;
89
- return (num + XMR_ATOMIC_PER_ONE - 1n) / XMR_ATOMIC_PER_ONE;
88
+ // ROUND A NON-NEGATIVE FRACTION TO A WHOLE UNIT: up, or down. Money is BigInt only (rule 10); the fraction is
89
+ // non-negative where this is reached (amounts and prices are positive, a budget is positive).
90
+ function divRound(num, den, up) {
91
+ const q = num / den;
92
+ return up && q * den < num ? q + 1n : q;
90
93
  }
91
94
 
92
- // HOW MANY XMR ATOMS A BUDGET GIVES WITHIN ONE LEVEL, ROUNDED DOWN (rule 6).
93
- function levelXmrForBudget(budgetWei, priceWeiPerXmr) {
94
- return (budgetWei * XMR_ATOMIC_PER_ONE) / priceWeiPerXmr;
95
+ // THE COST OF A CHUNK OF VOLUME IN THE ASSET, ONE LEVEL AT A TIME (rule 6). Rounding is always in the maker's
96
+ // favour: UP on ask, where the user pays, DOWN on bid, where the node pays the user. A walk rounds each level on
97
+ // its own and adds the levels up, as the node does: rounding the sum once would differ by up to a unit per level,
98
+ // and the node would sign the maker another amount than its journal books.
99
+ function levelCostWei(amountAtomic, priceWeiPerXmr, orientation) {
100
+ return divRound(amountAtomic * priceWeiPerXmr, XMR_ATOMIC_PER_ONE, orientation === "ask");
95
101
  }
96
102
 
97
- // THE FULL COST OF A SIDE (the sum of its levels' costs) and its volume - computed once.
98
- function sideSummary(levels) {
103
+ // HOW MANY XMR ATOMS A BUDGET GIVES WITHIN ONE LEVEL (rule 6). The answer in XMR is rounded the maker's way too:
104
+ // DOWN for a buyer (ask), UP for a seller (bid). At a coarse asset several atomic units pay the same, and a
105
+ // seller sends no more of them than the payout needs.
106
+ function levelXmrForBudget(budgetWei, priceWeiPerXmr, orientation) {
107
+ return divRound(budgetWei * XMR_ATOMIC_PER_ONE, priceWeiPerXmr, orientation === "bid");
108
+ }
109
+
110
+ // THE FULL COST OF A SIDE (the sum of its levels' costs, each rounded on its own) and its volume - computed once.
111
+ function sideSummary(levels, orientation) {
99
112
  let totalAtomic = 0n;
100
113
  let fullCostWei = 0n;
101
114
  for (const level of levels) {
102
115
  const amount = toBigInt(level.amount);
103
116
  const price = toBigInt(level.price);
104
117
  totalAtomic += amount;
105
- fullCostWei += levelCostWei(amount, price);
118
+ fullCostWei += levelCostWei(amount, price, orientation);
106
119
  }
107
120
  return { totalAtomic, fullCostWei };
108
121
  }
109
122
 
110
- // THE WALK WITHOUT THE FEE: a budget is given - how much XMR it yields (rounding down within a level, rule 6).
111
- function walkXmrForBudget(levels, budgetWei) {
123
+ // THE WALK WITHOUT THE FEE: a budget is given - how much XMR it yields (each level rounded on its own, rule 6).
124
+ function walkXmrForBudget(levels, budgetWei, orientation) {
112
125
  let remaining = budgetWei;
113
126
  let xmrAtomic = 0n;
114
127
  for (const level of levels) {
115
128
  const amount = toBigInt(level.amount);
116
129
  const price = toBigInt(level.price);
117
- const full = levelCostWei(amount, price);
118
- if (remaining >= full) {
130
+ const full = levelCostWei(amount, price, orientation);
131
+ // The maker's way once more: a buyer (ask) takes a level whole even when the budget only just reaches it; a
132
+ // seller (bid) sends no more than the payout needs, so an exact match is left to the partial step, rounded up.
133
+ const fits = orientation === "ask" ? remaining >= full : remaining > full;
134
+ if (fits) {
119
135
  xmrAtomic += amount;
120
136
  remaining -= full;
121
137
  continue;
122
138
  }
123
- xmrAtomic += levelXmrForBudget(remaining, price);
139
+ xmrAtomic += levelXmrForBudget(remaining, price, orientation);
124
140
  break;
125
141
  }
126
142
  return xmrAtomic;
127
143
  }
128
144
 
129
- // THE WALK WITHOUT THE FEE: an XMR volume is given - what it costs (each chunk's cost rounded up, rule 6).
130
- function walkCostForXmr(levels, xmrAtomic) {
145
+ // THE WALK WITHOUT THE FEE: an XMR volume is given - what it costs (each chunk's cost rounded on its own, rule 6).
146
+ function walkCostForXmr(levels, xmrAtomic, orientation) {
131
147
  let left = xmrAtomic;
132
148
  let costWei = 0n;
133
149
  for (const level of levels) {
@@ -135,7 +151,7 @@ function walkCostForXmr(levels, xmrAtomic) {
135
151
  const amount = toBigInt(level.amount);
136
152
  const price = toBigInt(level.price);
137
153
  const take = left < amount ? left : amount;
138
- costWei += levelCostWei(take, price);
154
+ costWei += levelCostWei(take, price, orientation);
139
155
  left -= take;
140
156
  }
141
157
  return costWei;
@@ -166,16 +182,16 @@ export function xmrForAsset({ levels, role, feeWei = 0n, assetWei, orientation =
166
182
  if (asset === null || fee === null || fee < 0n) return { ok: false, reason: "levels-not-integer", role };
167
183
  const budget = role === "buy" ? asset - fee : asset + fee;
168
184
  if (budget <= 0n) return { ok: false, reason: "levels-fee-beyond-budget", role };
169
- const { fullCostWei } = sideSummary(levels);
185
+ const { fullCostWei } = sideSummary(levels, orientation);
170
186
  if (budget > fullCostWei) return { ok: false, reason: "levels-exceed-side", role };
171
187
  // THE SMALLEST TRADE, from the set AS THE MAKER SENT IT (rule 3): callers pass it in minAtomic and the
172
188
  // node's deductions never change it. Absent, the set's own first level is used, as it always was.
173
189
  const min = minOf(levels, minAtomic);
174
190
  // Its cost ON THE EFFECTIVE SET: after deductions the smallest trade may reach into a later level, so the
175
191
  // cost is walked, not read off one price.
176
- const minCost = walkCostForXmr(levels, min);
192
+ const minCost = walkCostForXmr(levels, min, orientation);
177
193
  if (budget < minCost) return { ok: false, reason: "levels-below-minimum", role };
178
- const xmrAtomic = walkXmrForBudget(levels, budget);
194
+ const xmrAtomic = walkXmrForBudget(levels, budget, orientation);
179
195
  return { ok: true, role, xmrAtomic, assetWei: asset };
180
196
  }
181
197
 
@@ -193,7 +209,7 @@ export function assetForXmr({ levels, role, feeWei = 0n, xmrAtomic, orientation
193
209
  if (want > verdict.totalAtomic) return { ok: false, reason: "levels-exceed-side", role };
194
210
  // THE SMALLEST TRADE AS THE MAKER SENT IT, never the effective set's first level (rule 3): see minOf.
195
211
  if (want < minOf(levels, minAtomic)) return { ok: false, reason: "levels-below-minimum", role };
196
- const walkWei = walkCostForXmr(levels, want);
212
+ const walkWei = walkCostForXmr(levels, want, orientation);
197
213
  const assetWei = role === "buy" ? walkWei + fee : walkWei - fee;
198
214
  if (assetWei <= 0n) return { ok: false, reason: "levels-fee-beyond-budget", role };
199
215
  return { ok: true, role, xmrAtomic: want, assetWei };
@@ -1,8 +1,8 @@
1
1
  // EIP-712 ORDER-QUOTE SIGNATURE - THE SDK SIDE (VERIFICATION ONLY).
2
2
  //
3
3
  // The SDK verifies, it does not sign: the maker's wallet key never reaches the customer's machine. The spec
4
- // (types, domain, digest, recovery) lives in quoteEip712Spec.mjs, byte-identical to the node's copy and pinned
5
- // by tools/check-quote-eip712.mjs. Here it is only bound to the SDK's own crypto primitives.
4
+ // (types, domain, digest, recovery) lives in quoteEip712Spec.mjs - the ONE embodiment: the node takes it from the
5
+ // package entry, so there is no second copy (tools/check-quote-eip712.mjs). Here it is only bound to SDK primitives.
6
6
  //
7
7
  // The rule the old flag carried is kept: a quote that cannot be verified is REFUSED. A missing field, a
8
8
  // missing signature or an unusable domain is not "probably fine" - the caller names it with a code.
@@ -1,9 +1,9 @@
1
1
  // EIP-712 TYPED DATA FOR AN ORDER QUOTE - THE SPEC, SHARED BY THE NODE AND THE SDK.
2
2
  //
3
- // WHY THE SAME FILE EXISTS TWICE. The node (rfq/) runs on the provider's machine and never sees www/ or sdk/:
4
- // it has to start on its own. The SDK is a package and never sees rfq/. So the spec is embodied twice and the
5
- // two copies are pinned by sha256 (tools/check-quote-eip712.mjs), exactly like the order context. A divergence
6
- // here would mean one side signs what the other does not verify - the failure this format exists to prevent.
3
+ // WHO HOLDS IT. The node (rfq/) runs on the provider's machine and does not read our files; it takes this spec
4
+ // from the package entry (@ninsei-labs/sdk/order), so there is ONE embodiment here and no copy on the node's side
5
+ // (that wiring is pinned by tools/check-quote-eip712.mjs). A divergence here would mean one side signs what the
6
+ // other does not verify - the failure this format exists to prevent.
7
7
  //
8
8
  // WHAT IS BOUND, AND WHY EACH FIELD IS INSIDE THE DIGEST (changing any of them breaks recovery):
9
9
  // provider, quoteKey - whose quote it is and which key signed it. For a solo maker the two are equal; a
package/src/swap-flow.mjs CHANGED
@@ -28,6 +28,10 @@
28
28
  // is the RULES, not the crypto.
29
29
  import { SdkError } from "./errors.mjs";
30
30
  import * as engine from "./engine.mjs";
31
+ // THE MAKER'S DECLINE (issue #40) - the escrow's `Declined` event and the neutral vocabulary the state is
32
+ // reported in. It is its own module because TWO surfaces ask the same question (this flow and the swaps watch)
33
+ // and a second implementation of the same event would be a second truth about it.
34
+ import { declinedFromLogs, logReaderOf, blockBoundaryOf, DECLINED_TOPIC0 } from "./decline.mjs";
31
35
 
32
36
  // THE ENGINE SURFACES, TAKEN THROUGH THE ONE SEAM (./engine.mjs) - exactly as the rest of the core does.
33
37
  const { chainById, DEFAULT_CHAIN, MONERO } = engine.config;
@@ -354,6 +358,30 @@ export async function orderStatus(escrow, deps = {}) {
354
358
  return decodeStatus(raw);
355
359
  }
356
360
 
361
+ /**
362
+ * THE ESCROW'S `Declined` EVENT - THE MAKER'S OWN CLOSING OF A FUNDED ORDER (issue #40).
363
+ *
364
+ * WHY THE EVENT AND NOT `status()`. Read on its own, `status()` cannot tell a decline from an ordinary refund:
365
+ * both set `refunded` and send the whole balance back to the locker. The escrow keeps NO getter that names a
366
+ * decline (there is no `declined()` view), so the event is the on-chain fact that does - `decline()` emits
367
+ * `Declined(claimer)` and a refund emits `HalfRevealed(half, false)` instead.
368
+ *
369
+ * THE SEAM IS THE READER, AS EVERYWHERE ELSE: `deps.logs` is `({ address, topics, fromBlock, toBlock }) -> logs`
370
+ * and by default the chain is read through the page's wallet (`readDeclinedLogs`), so the wallet that reads is
371
+ * the wallet that would sign. `deps.fromBlock` is the caller's: a caller that knows the escrow's birth height
372
+ * scans from there instead of walking the whole chain.
373
+ *
374
+ * An answer that is not a list of logs is a REFUSAL (`bad-input`), never "no decline": an unread chain must not
375
+ * be reported as an escrow that declined nothing.
376
+ */
377
+ export async function orderDecline(escrow, deps = {}) {
378
+ if (typeof escrow !== "string" || !escrow) throw new SdkError("bad-input", { field: "escrow" });
379
+ const logs = logReaderOf(deps.logs);
380
+ const fromBlock = deps.fromBlock === undefined ? "earliest" : blockBoundaryOf(deps.fromBlock);
381
+ const answer = await logs({ address: escrow, topics: [DECLINED_TOPIC0], fromBlock, toBlock: "latest" });
382
+ return declinedFromLogs(answer, { escrow, at: Number.isFinite(Number(deps.at)) ? Number(deps.at) : null });
383
+ }
384
+
357
385
  // THE ORDER DEADLINE IS ONE - t1. Before it the claimer takes the ETH by its half and only with the ready mark
358
386
  // (:215); from t1 claiming is forbidden (:216), and anyone may refund by PRESENTING THE DEPOSITOR'S HALF
359
387
  // (:232-235) - the watchtower does not have it, and that limitation is named in doc 24 section 1. The deadline
package/src/swaps.mjs CHANGED
@@ -25,9 +25,44 @@ import { primitives } from "./primitives.mjs";
25
25
  import { createActions } from "./actions.mjs";
26
26
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
27
27
  import * as xmrArrival from "./xmr-arrival.mjs";
28
+ // THE MAKER'S DECLINE (issue #40): the escrow's `Declined` event and the node's `declined` + `compliance` status
29
+ // are TWO independent sources of the same state, and either one is enough. The detectors and the neutral
30
+ // vocabulary live in one module, so this file names the state instead of re-reading the two.
31
+ import { declineOf, logReaderOf, blockBoundaryOf, DECLINED_TOPIC0, DECLINED_TERMINAL, DECLINED_CODE } from "./decline.mjs";
28
32
 
29
- // The engine's terminal states -> the contract's codes. An unknown kind is not invented: it comes as "closed".
30
- const TERMINAL = { success: "success", refunded: "refunded_eth", xmr_returned: "xmr_returned", closed: "closed" };
33
+ // THE ENGINE'S SETTLEMENT KINDS -> THE CONTRACT'S CODES: THE KEY IS THE KIND THE ENGINE REALLY WRITES.
34
+ //
35
+ // The map used to be keyed by names no code path writes - `refunded` for a refund (the engine writes
36
+ // `kind: "refund_eth"`), and `xmr_returned` and `closed`, for which no settlement with that kind is written
37
+ // anywhere in the engine or in this package. A REFUND therefore never met its key and fell into the fallback:
38
+ // get/list/progress told the app `closed` about an order whose ETH was already back in the wallet - the very
39
+ // distinction that became load-bearing once the maker's decline was made a state of its own (issue #40).
40
+ //
41
+ // WHAT CAN BE A KIND, EXHAUSTIVELY. The record's settlement belongs to the engine (engine/core/swap.js, the only
42
+ // module that assigns swap.settlement), and `declined` is the one kind written by us (src/decline.mjs):
43
+ // `success` - the swap completed and the XMR is on the depositor's address (sweepNow, the auto-sweep in the
44
+ // ticker, and the backend-reported sweep on the live path);
45
+ // `refund_eth` - the ETH went back to the depositor. TWO WRITERS, ONE KIND: the person's own refund (refundEth)
46
+ // and the watchtower inside the ticker both write `kind: "refund_eth"` with the phase
47
+ // `refunded_eth`, so there is exactly ONE refund-shaped kind here and nothing is collapsed;
48
+ // `declined` - the maker refused a funded order. Not the engine's: the escrow reads `refunded` either way, so
49
+ // the engine cannot tell a decline from a refund, and the SDK writes this kind itself when it SEES
50
+ // the decline (src/decline.mjs). From then on every surface that reads the record reports it
51
+ // without asking the chain again.
52
+ //
53
+ // AND WHAT IS NOT A KEY: an unknown kind is named, not invented - it comes out as "closed", the fallback in
54
+ // toState below, which is why `closed` is a code here and not a key (no settlement carries that kind; the string
55
+ // `closed` in the engine is the XMR SESSION's status vocabulary, not a settlement). The maker taking the XMR back
56
+ // without a ready mark is NOT a kind either: it does not end a swap - the depositor's ETH is still in the escrow -
57
+ // so it is a PHASE of a live swap (`xmr_reclaimed`, engine/core/swap.js, told apart by that code from the maker who
58
+ // never locked), and the swap ends later as `refunded_eth`. `xmr_returned`, which stood in the engine's terminal
59
+ // table and in the `TerminalCode` type as a state nothing could produce, is gone with it. Every one of these facts
60
+ // is asserted by the `terminal` check rather than left to a reader.
61
+ const TERMINAL = {
62
+ success: "success",
63
+ refund_eth: "refunded_eth",
64
+ [DECLINED_TERMINAL]: DECLINED_TERMINAL,
65
+ };
31
66
 
32
67
  const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
33
68
 
@@ -159,6 +194,10 @@ export function toState(swap) {
159
194
  });
160
195
  }
161
196
  const active = steps.find((s) => s.active) || [...steps].reverse().find((s) => s.done) || steps[0];
197
+ // THE TERMINAL STATE IS TRANSLATED ONCE, AND THE DECLINE IS READ OFF IT. A declined order is NOT a refunded
198
+ // one, and the difference is drawn here and in the action flags below, not left to the screen.
199
+ const terminal = d.terminal ? (TERMINAL[d.terminal.kind] ?? "closed") : null;
200
+ const declined = terminal === DECLINED_TERMINAL;
162
201
  const chain = swap.network || swap.chain || null;
163
202
  // The virtual machine is taken from the network registry. An unknown network has none, and "unknown" is more honest
164
203
  // than the network's name: it is visible in the state and does not turn into "probably EVM".
@@ -214,9 +253,16 @@ export function toState(swap) {
214
253
  status: d.phase || null,
215
254
  txs: (swap.escrow && (swap.escrow.txids || swap.escrow.txs)) || [],
216
255
  },
217
- actions: { markReady: !!(d.can && d.can.ready), sweep: !!(d.can && d.can.sweep), refund: !!(d.can && d.can.refund) },
256
+ // A DECLINED ORDER OFFERS NOTHING, AND THAT IS THE POINT OF THE STATE (issue #40). The ETH - the fee
257
+ // included - is already back in the depositor's wallet and no half was revealed, so there is no ready mark to
258
+ // place, no XMR to sweep and NO REFUND TO SIGN: the flags are OFF rather than left to whatever the engine's
259
+ // `can` says about an order whose escrow merely reads `refunded`. The app needs no second source for "there
260
+ // is nothing to do": this state plus the terminal code says it.
261
+ actions: declined
262
+ ? { markReady: false, sweep: false, refund: false }
263
+ : { markReady: !!(d.can && d.can.ready), sweep: !!(d.can && d.can.sweep), refund: !!(d.can && d.can.refund) },
218
264
  simulated: { evm: !!(d.simulated && d.simulated.evm), xmr: !!(d.simulated && d.simulated.xmr) },
219
- terminal: d.terminal ? (TERMINAL[d.terminal.kind] ?? "closed") : null,
265
+ terminal,
220
266
  };
221
267
  }
222
268
 
@@ -268,11 +314,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
268
314
  // The check on the ACTUAL sender is done in start() below, ONE place, right before the send - the wallet adapter
269
315
  // only HANDS OUT the current account (`address()`), it does not carry the policy.
270
316
  denylist = null,
271
- watch: watchOptions = null } = {}) {
317
+ watch: watchOptions = null,
318
+ // THE CHAIN LOG READER FOR THE DECLINE CHECK (issue #40). A seam, as the chain read is: by default the logs are
319
+ // read through the page's wallet (`readDeclinedLogs`), and from outside a caller brings its own
320
+ // ({ address, topics, fromBlock, toBlock }) -> logs. It is a READ of the escrow's own event, not a node
321
+ // substitution - the address is the escrow of the swap being watched.
322
+ logs: chainLogs = null } = {}) {
272
323
  // WHAT READS THE CHAIN AT THE LOCK IS A SEAM, as in actions.mjs: by default the engine's read, from outside you can
273
324
  // substitute your own. Otherwise the lock step could not be checked beyond predicting the address - and "the address
274
325
  // from the receipt" would remain an argument on trust, not a verified property.
275
326
  const reader = typeof call === "function" ? call : engine.evm.readContract;
327
+ // THE SAME RULE FOR THE LOGS: one reader per facade, chosen once.
328
+ const logReader = logReaderOf(chainLogs);
276
329
  // MARKING READY GOES THROUGH THE SAME READ SEAM as the lock: checking the slots and signing must go one way,
277
330
  // otherwise part of the check goes past the wallet and part does not (see actions.mjs).
278
331
  const actions = createActions({ call: typeof call === "function" ? call : null });
@@ -880,10 +933,82 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
880
933
  if (readyBy) xmrArrival.assertTimeForConfirmations({ readyBySec: readyBy, nowSec: Math.floor(Date.now() / 1000), minConfirmations, blockTimeSec: limitOf("xmrBlockTimeSec", 120) });
881
934
  }
882
935
  const started = Date.now();
936
+ // --- THE MAKER'S DECLINE: THE WAIT IS NOT STARTED AT ALL (issue #40). -------------------------------------
937
+ // A decline leaves the escrow `refunded`, so nothing in the state says "do not wait any longer": without
938
+ // this, the wait runs to its timeout and the app is told the generic `xmr-timeout` about an order that is
939
+ // CLOSED and whose ETH is already back - or, worse, the app offers the recovery file and a manual refund
940
+ // for money nobody can recover.
941
+ //
942
+ // THE DECLINE IS ASKED WITH TWO INDEPENDENT SOURCES AND EITHER ONE ENDS THE WAIT:
943
+ // * THE CHAIN - the escrow's own `Declined` event, through the same log reader the rest of the core uses;
944
+ // * THE WIRE - the status the service answers with (`declined` with the code `compliance`).
945
+ // A CHAIN READ THAT FAILS is neither a decline nor a refusal of the swap: the wait goes on and the wire may
946
+ // still answer. What the chain read did is carried out with the result (`chain.checked` / `chain.error`), so
947
+ // a broken reader is visible instead of silent.
948
+ // A STORAGE THAT CANNOT BE READ IS NOT A REASON TO REFUSE THE WAIT: the record is what makes the state
949
+ // durable, and the escrow may also arrive in the request. The watch is not a new user of the store - and it
950
+ // must not start failing where it did not before.
951
+ let record = null;
952
+ try { record = engine.swap.getSwap(id); } catch { record = null; }
953
+ const escrowAddress = request.escrow || (record && record.escrow && record.escrow.address) || null;
954
+ // FROM THE ESCROW'S BIRTH HEIGHT when the record knows it: the event cannot be older than the funding, and
955
+ // walking the whole chain every tick is a cost with no answer behind it.
956
+ const fromBlock = blockBoundaryOf(record && record.escrow ? record.escrow.birthHeight : null);
957
+ const chainDecline = { checked: false, error: null, logs: undefined };
958
+ const readDeclineLogs = async () => {
959
+ chainDecline.checked = false; chainDecline.error = null; chainDecline.logs = undefined;
960
+ // NO ESCROW KNOWN, NO CHAIN ANSWER: named, so `checked: false` is never a silent "nothing there".
961
+ if (!escrowAddress) { chainDecline.error = "no-escrow"; return; }
962
+ try {
963
+ chainDecline.logs = await logReader({ address: escrowAddress, topics: [DECLINED_TOPIC0], fromBlock, toBlock: "latest" });
964
+ chainDecline.checked = true;
965
+ } catch (error) {
966
+ chainDecline.error = (error && error.code) || "logs-unread";
967
+ }
968
+ };
969
+ // WRITTEN, NAMED, NOT INFERRED - the rule the record's destination follows too. The decline becomes the
970
+ // record's OWN settlement once, so later reads of the record report the same state without asking the chain
971
+ // again, and the state survives a page reload.
972
+ const settleDecline = (swap, at) => {
973
+ if (!swap || swap.settlement) return;
974
+ swap.settlement = { kind: DECLINED_TERMINAL, phase: DECLINED_TERMINAL, at };
975
+ try { engine.swap.saveSwap(swap); } catch { throw new SdkError("storage-unavailable", { step: "decline" }); }
976
+ };
977
+ // THE STATE THE WATCH REPORTS FOR A DECLINE: no arrival, the state's own code, the node's own token and
978
+ // WHERE it was seen ("event", "status", or "record" when it was already written down). NO reason, category
979
+ // or provider travels here - the node names none, and the sentence a person reads is the interface's.
980
+ const declineResult = ({ source, at, waitedMs, address, chainChecked, chainError }) => ({
981
+ id,
982
+ escrow: escrowAddress,
983
+ address: address || null,
984
+ seen: false,
985
+ declined: true,
986
+ terminal: DECLINED_TERMINAL,
987
+ code: DECLINED_CODE,
988
+ source,
989
+ at,
990
+ waitedMs,
991
+ chain: { checked: Boolean(chainChecked), error: chainError || null },
992
+ });
993
+ if (record && record.settlement && record.settlement.kind === DECLINED_TERMINAL) {
994
+ const at = Number(record.settlement.at);
995
+ return declineResult({ source: "record", at: Number.isFinite(at) ? at : Date.now(), waitedMs: 0,
996
+ address: null, chainChecked: false, chainError: null });
997
+ }
883
998
  let last = null;
884
999
  for (;;) {
1000
+ await readDeclineLogs();
885
1001
  const body = await readWatch(id);
886
1002
  last = incomingOf(body);
1003
+ // THE DECLINE IS DECIDED BEFORE THE ARRIVAL IS LOOKED AT, and before the "foreign response" refusal below:
1004
+ // a declined swap's answer carries no `receivedXmr` at all, and refusing it as a bad response would name
1005
+ // the wrong thing (the state is known - it is a decline, not a broken answer).
1006
+ const declineNow = declineOf({ logs: chainDecline.logs, status: body, escrow: escrowAddress, at: Date.now() });
1007
+ if (declineNow.declined) {
1008
+ settleDecline(record, declineNow.at);
1009
+ return declineResult({ source: declineNow.source, at: declineNow.at, waitedMs: Date.now() - started,
1010
+ address: body && body.address, chainChecked: chainDecline.checked, chainError: chainDecline.error });
1011
+ }
887
1012
  // AN EMPTY OR FOREIGN RESPONSE IS NOT AN ARRIVAL: counting it as incoming would mean declaring money that was
888
1013
  // never seen. This is a named refusal, not "let us wait some more".
889
1014
  if (!last) throw new SdkError("server-unavailable", { step: "watch", why: "bad-response", id });
package/src/version.mjs CHANGED
@@ -6,4 +6,4 @@
6
6
  // tools/sync-version.mjs. Do not edit it by hand - set the version in package.json and run `npm run sync:version`.
7
7
  // The `version` check in tools/check.mjs refuses a mismatch, and refuses a runtime read of package.json here.
8
8
 
9
- export const SDK_VERSION = "0.42.0";
9
+ export const SDK_VERSION = "0.43.1";