@ninsei-labs/sdk 0.42.0 → 0.42.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,19 @@
1
+ ## 0.42.1 — 2026-10-12
2
+
3
+ THE BID SIDE OF THE QUOTE WALK ROUNDED THE WRONG WAY. The specification fixes a rounding DIRECTION per side, and
4
+ the walk rounded both sides alike: a bid price came out up to 3 wei high and disagreed with the reference at a
5
+ level boundary.
6
+
7
+ - Per-level rounding follows the side now: a level's cost rounds up on the ask side, where the user pays, and down
8
+ on the bid side, where the user is paid; the XMR amount a budget yields at a level rounds up on the bid side.
9
+ - The level-fits test is asymmetric: a level is taken whole when `remaining >= full` on the ask side and when
10
+ `remaining > full` on the bid side.
11
+ - This removes a systematic 1-to-3-wei over-charge on bid prices and the disagreement with the reference at level
12
+ boundaries.
13
+
14
+ Checks: `node tools/check.mjs` - all 19 green; `node tools/sync-version.mjs`, `node tools/npm-tag.test.mjs`, the two
15
+ teeth runs and `tools/sweep-refusal.test.mjs` are green too.
16
+
1
17
  ## 0.42.0 — 2026-10-12
2
18
 
3
19
  EVERY ENTRY THIS PACKAGE PUBLISHES CARRIES ITS OWN DECLARATIONS, AND THE SWEEP PAGE'S TWO PRIMITIVES ARE ON
package/index.d.ts CHANGED
@@ -500,7 +500,8 @@ export interface QuoteOffer {
500
500
  * The providers already name them in their own tables (`COW_REFUSAL_CODES` and its three siblings), and the
501
501
  * battery proves each of those is reachable; what was missing was the TYPE, so an interface could enumerate
502
502
  * 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.
503
+ * case a provider could not classify. `RouteRefusalTables` below declares what those tables ARE - each of them a
504
+ * `readonly RouteRefusalCode[]` - so the union is the tables' TYPE and not a comment standing beside them.
504
505
  *
505
506
  * These are OUR codes, and they are what a person's message should be phrased from. A provider's OWN reason
506
507
  * stays opaque on purpose (`QuoteRefusal.why`): that is data the provider sent, not a name of ours, so it is
@@ -546,6 +547,28 @@ export type RouteRefusalCode =
546
547
  // shared
547
548
  | "broken";
548
549
 
550
+ /**
551
+ * WHAT THE FOUR REFUSAL TABLES *ARE*. Each route provider names its own refusals in one table - `COW_REFUSAL_CODES`
552
+ * (src/legs/cow.mjs), `KYBER_REFUSAL_CODES` (src/legs/kyber.mjs), `UNISWAPX_REFUSAL_CODES` (src/legs/uniswapx.mjs) and
553
+ * `UNISWAPRELAY_REFUSAL_CODES` (src/legs/uniswaprelay.mjs) - and every one of those tables is a
554
+ * `readonly RouteRefusalCode[]`. The dependency runs ONE WAY on purpose: a table answers to the union (a code that is
555
+ * not declared above cannot enter a table), while the union answers to no single table, so the same vocabulary is
556
+ * met in four modules.
557
+ *
558
+ * THEY ARE NOT ON THIS ENTRY'S SURFACE, and this is deliberately NOT a value: the package re-exports no provider and
559
+ * no table (src/index.mjs), the tables travel in the shipped source (`files` in package.json) and they are the
560
+ * VOCABULARY, not an API. A `declare const` here that the runtime does not export would be a lie, and the `surface`
561
+ * check refuses exactly that - so the tables are declared as their TYPE. That is what keeps the union from being a
562
+ * comment beside the tables: this declaration says what they are, and the `refusals` check says the same thing from
563
+ * the other side - a table declared here as a plain `readonly string[]` is a named problem there.
564
+ */
565
+ export interface RouteRefusalTables {
566
+ readonly COW_REFUSAL_CODES: readonly RouteRefusalCode[];
567
+ readonly KYBER_REFUSAL_CODES: readonly RouteRefusalCode[];
568
+ readonly UNISWAPX_REFUSAL_CODES: readonly RouteRefusalCode[];
569
+ readonly UNISWAPRELAY_REFUSAL_CODES: readonly RouteRefusalCode[];
570
+ }
571
+
549
572
  export interface QuoteRefusal {
550
573
  readonly providerId: string;
551
574
  readonly displayName: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.42.0",
3
+ "version": "0.42.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -20,6 +20,8 @@
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",
23
25
  "check:sweep": "node tools/sweep-refusal.test.mjs", "sync:version": "node tools/sync-version.mjs"
24
26
  },
25
27
  "types": "./index.d.ts",
@@ -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 };
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.42.1";