@ninsei-labs/sdk 0.39.2 → 0.39.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,31 @@
1
+ ## 0.39.3 — 2026-10-09
2
+
3
+ A NODE'S ADDRESS DENYLIST REFUSAL REACHES THE APP AS ITS OWN CODE, AND THE INTERFACE CAN CHECK ADDRESSES ITSELF (issue #16).
4
+ A maker's node refuses a firm quote to an address on the maker's denylist (`PUT /v1/denylist`) and answers
5
+ `{ok:false, code:"denied-address", why:"..."}`. Until now `quotes.firm` read the answer with `http.json`, which throws
6
+ `server-unavailable` and LOSES the body on any non-2xx: the app was told the node was unavailable when the node had
7
+ answered and refused, and it could not tell "did not answer" from "answered and refused".
8
+
9
+ - **`quotes.firm` reads the answer as a VALUE** (`http.tryJson`, `src/quotes.mjs`): a failed CONNECTION is still
10
+ `server-unavailable`, while an answer that refused is its own refusal code (`quote-refused`), not "unavailable".
11
+ - **The node's `denied-address` refusal reaches the app under the SDK code `address-denied`** (`src/codes.mjs`,
12
+ `index.d.ts`), WITHOUT the node's `why` text and WITHOUT the address: the node names both, and the interface shows
13
+ its own neutral message. A backend relaying the quote passes the node's `code` through (the demo's
14
+ `app/server.mjs` `/api/order-quote` relay now forwards it).
15
+ - **A new optional address check: `createNinsei({ ..., denylist: [addresses] })`** (`src/index.mjs`, `src/quotes.mjs`,
16
+ `src/swaps.mjs`, `index.d.ts`). Compared WITHOUT CASE; an empty/absent list means "no check"; the SDK fetches
17
+ nothing - the interface brings the list in. Before `quotes.firm` the order's `locker` is checked (on a purchase the
18
+ claimer is the node's own address, so it is deliberately not checked); before `swaps.start` the ACTUAL sender is
19
+ checked - the address the funding transaction will really be sent from, read from the wallet adapter's `address()`
20
+ (`eth_accounts[0]`) at the moment of sending, because the person may have switched accounts. A barred address is
21
+ refused with `address-denied` BEFORE any request; an adapter that cannot hand out the current account is refused
22
+ with `bad-input` rather than skipping the policy silently.
23
+ - Checks (`tools/check-sdk.mjs`): the node's `denied-address` answer yields `address-denied` with neither the address
24
+ nor the `why` in `params`; a plain refusal yields `quote-refused` while a failed connection yields
25
+ `server-unavailable`; a denylisted `order.locker` is refused before any request (zero calls seen); a denylisted
26
+ actual sender stops the lock before it signs (no transaction); the comparison is case-insensitive; an empty list
27
+ changes nothing. The breaking run removes the `denied-address` mapping and the pre-send check and must redden.
28
+
1
29
  ## Unreleased — 2026-10-08
2
30
 
3
31
  EVERY ORDER'S ESCROW IS A CLONE, AND THE CLIENT NOW CHECKS THE IMPLEMENTATION IT WILL EXECUTE (issue #114).
package/README.md CHANGED
@@ -96,7 +96,7 @@ Terminal states (`state.terminal`): `success`, `refunded_eth`, `xmr_returned`, `
96
96
  ### Errors (`error.code`, `SdkError.code`)
97
97
 
98
98
  `not-implemented`, `bad-input`, `wallet-not-connected`, `wallet-rejected`, `wrong-chain`, `quote-unavailable`,
99
- `quote-refused`, `quote-stale`, `recovery-declined`, `recovery-failed`, `storage-unavailable`,
99
+ `quote-refused`, `address-denied`, `quote-stale`, `recovery-declined`, `recovery-failed`, `storage-unavailable`,
100
100
  `server-unavailable`, `server-refused`, `xmr-timeout`, `xmr-locked`, `contract-reverted`, `insufficient-funds`, `unknown`.
101
101
 
102
102
  Three refusals that are easy to merge into one are named DIFFERENTLY, because the advice differs:
@@ -105,6 +105,9 @@ reason and status are in `params`; investigate); `xmr-timeout` — the wait for
105
105
  state of the money, not a breakdown); `xmr-locked` — XMR is on the address but the transaction's `unlock_time`
106
106
  barred it until a height or a date (`params.until`), so the funds are not an arrival and the ready mark is not
107
107
  signed (issue #84).
108
+ `address-denied` (issue #16) - the address is on the interface's `denylist` (refused before any
109
+ request) or the node's own `denied-address` refusal, WITHOUT the node's `why` text and WITHOUT the address: the
110
+ interface shows its own neutral message.
108
111
 
109
112
  ## Quotes
110
113
 
package/index.d.ts CHANGED
@@ -105,6 +105,18 @@ export interface NinseiOptions {
105
105
  * An empty/absent list means "no policy": the pair is still read and the implementation's code must be non-empty.
106
106
  */
107
107
  knownImplementationCodes?: readonly string[];
108
+ /**
109
+ * THE INTERFACE'S ADDRESS DENYLIST (issue #16) - policy, not proof, next to allowedProviders. Addresses the
110
+ * interface will not trade to or from, compared WITHOUT CASE. The SDK fetches nothing: the interface brings the
111
+ * list in. An empty/absent list means "no check".
112
+ * Before `quotes.firm` the order's `locker` is checked (on a purchase the claimer is the node's own address, so
113
+ * it is deliberately not checked), and before `swaps.start` the ACTUAL sender - the address the funding will be
114
+ * sent from (`eth_accounts[0]` read from the wallet adapter at the moment of sending on demand; the person may
115
+ * have switched accounts). A barred address is refused with `address-denied` BEFORE any request. A node's own
116
+ * `denied-address` refusal reaches the app under the same code, WITHOUT the node's `why` text and WITHOUT the
117
+ * address: the interface shows its own neutral message.
118
+ */
119
+ denylist?: readonly string[];
108
120
  /**
109
121
  * A PASS TO OUR SERVICE for the "server" step (recording the swap with us). Only a wallet can sign the sign-in
110
122
  * message, and the wallet's role in the SDK is an adapter: windows and message signatures remain the interface's,
@@ -1278,6 +1290,12 @@ export type ErrorCode =
1278
1290
  | "wrong-chain"
1279
1291
  | "quote-unavailable"
1280
1292
  | "quote-refused"
1293
+ /**
1294
+ * An address on the interface's `denylist` refused BEFORE any request - the order's `locker` before `quotes.firm`,
1295
+ * or the actual sender before `swaps.start` (issue #16). Also names the node's own `denied-address` refusal as it
1296
+ * reaches the app: WITHOUT the node's `why` text and WITHOUT the address, so the interface shows its own message.
1297
+ */
1298
+ | "address-denied"
1281
1299
  | "quote-stale"
1282
1300
  /** The order-quote signature (EIP-712, v6) did not recover to any address. */
1283
1301
  | "quote-signature-invalid"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.39.2",
3
+ "version": "0.39.3",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/codes.mjs CHANGED
@@ -42,6 +42,11 @@ export const ERROR_CODES = [
42
42
  "wrong-chain",
43
43
  "quote-unavailable",
44
44
  "quote-refused",
45
+ // A NODE'S ADDRESS DENYLIST REFUSAL (issue #16): the maker's node answers {ok:false, code:"denied-address", why}
46
+ // before it prices the order, and that refusal reaches the app under THIS code - WITHOUT the node's `why` text
47
+ // and WITHOUT the address, because the interface shows the person its own neutral message. It is its own code,
48
+ // not "quote-refused": the advice to the interface differs ("this address is barred" vs "the quote was refused").
49
+ "address-denied",
45
50
  "quote-stale",
46
51
  // THE ORDER-QUOTE SIGNATURE (EIP-712, quote format v7). Four different refusals, because the interface owes the
47
52
  // person a different piece of advice for each:
package/src/index.mjs CHANGED
@@ -105,6 +105,12 @@ const normalizeOptions = (options) => {
105
105
  if (o.knownImplementationCodes !== undefined && o.knownImplementationCodes !== null && !Array.isArray(o.knownImplementationCodes)) {
106
106
  fail("bad-input", { field: "knownImplementationCodes" });
107
107
  }
108
+ // THE INTERFACE'S ADDRESS DENYLIST (issue #16): policy, like allowedProviders - addresses the interface will not
109
+ // trade to or from. An empty/absent list means "no check"; an entry that is not a string is a typo, not a silent
110
+ // no-op. The SDK fetches nothing: the interface brings the list in.
111
+ if (o.denylist !== undefined && o.denylist !== null) {
112
+ if (!Array.isArray(o.denylist) || o.denylist.some((a) => typeof a !== "string")) fail("bad-input", { field: "denylist" });
113
+ }
108
114
  // A PASS TO OUR SERVICE - A SEAM FROM OUTSIDE, like the wallet: only a wallet can sign the sign-in message
109
115
  // (www/js/evm/auth.js, signIn), and requiring it of the core would mean opening windows and signing messages
110
116
  // inside the SDK. The core receives a ready pass and presents it. A missing pass is NOT an option error: the
@@ -146,6 +152,7 @@ const normalizeOptions = (options) => {
146
152
  allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
147
153
  knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
148
154
  knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
155
+ denylist: o.denylist === undefined ? null : o.denylist,
149
156
  serverAuth: o.serverAuth || null,
150
157
  watch: o.watch || null,
151
158
  createRecord: o.createRecord || null,
@@ -194,8 +201,15 @@ export function createNinsei(options) {
194
201
  const set = new Set(a.map((v) => String(v).toLowerCase()));
195
202
  return (provider) => set.has(String(provider).toLowerCase());
196
203
  })();
204
+ // THE DENYLIST IS LOWER-CASED ONCE HERE, so the comparison rule (case-insensitive membership) lives in ONE
205
+ // place and BOTH the quote check (`quotes.firm`) and the lock check (`swaps.start`) use it. An empty list is
206
+ // "no policy" (null), not an empty check.
207
+ const denylist = Array.isArray(o.denylist) && o.denylist.length
208
+ ? new Set(o.denylist.map((a) => String(a).toLowerCase()))
209
+ : null;
197
210
  const quotes = createQuotes({ config, http, now: o.now || (() => Date.now()), evmCall: o.evmCall || null,
198
- evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes });
211
+ evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
212
+ denylist });
199
213
  // THE SAME CHAIN-READ SEAM AS THE LOCK AND THE CORE: checking the slots and signing must go one way, otherwise
200
214
  // part of the check goes past the wallet and part does not.
201
215
  const actions = createActions({ call: o.evmCall });
@@ -246,7 +260,7 @@ export function createNinsei(options) {
246
260
  // would not know where to knock, and without the pass there is nothing to present to the service (and that
247
261
  // is a refusal with a CODE, not an invented path).
248
262
  swaps: createSwaps({ preflight, http, mode: o.mode, xmrNetwork: o.xmrNetwork, call: o.evmCall,
249
- config, auth: o.serverAuth, watch: o.watch,
263
+ config, auth: o.serverAuth, denylist, watch: o.watch,
250
264
  // THE RECORD IS BUILT HERE ITSELF, and one brought from outside is only the caller's preference.
251
265
  createRecord: o.createRecord }),
252
266
  // XMR WITHDRAWAL: parsing the file and requesting data work; the withdrawal itself is named (the Monero
package/src/quotes.mjs CHANGED
@@ -208,7 +208,7 @@ const compactOffer = (o) => ({
208
208
  // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
209
  const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
210
 
211
- export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null }) {
211
+ export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
212
212
  // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
213
  // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
214
  // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
@@ -340,9 +340,19 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
340
340
  if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
341
341
  if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
342
342
  if (!request.order || typeof request.order !== "object") fail("bad-input", { field: "order" });
343
- let body = null;
343
+ // THE INTERFACE'S OWN ADDRESS DENYLIST (issue #16): POLICY, not proof - the interface refuses to ask for a
344
+ // quote for an address on its own list, and does it BEFORE any request is made. `order.locker` is the
345
+ // address the funds come from on a purchase (the claimer there is the node's own address, so it is
346
+ // deliberately not checked). An empty/absent list is "no policy"; the comparison is WITHOUT CASE.
347
+ if (denylist && typeof request.order.locker === "string" && denylist.has(request.order.locker.toLowerCase())) {
348
+ fail("address-denied", { step: "order-quote" });
349
+ }
350
+ let res = null;
344
351
  try {
345
- body = await http.json(config.route("orderQuote"), {
352
+ // THE ANSWER IS READ AS A VALUE (tryJson), NOT AS AN EXCEPTION: "did not answer" and "answered and
353
+ // refused" must stay different, and a refusal's own body carries the reason. `json` throws
354
+ // server-unavailable and LOSES that body.
355
+ res = await http.tryJson(config.route("orderQuote"), {
346
356
  method: "POST",
347
357
  headers: { "content-type": "application/json" },
348
358
  body: JSON.stringify({ providerId: request.providerId, order: request.order }),
@@ -351,7 +361,15 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
351
361
  if (error && error.code === "bad-input") throw error;
352
362
  fail("server-unavailable", { step: "order-quote" });
353
363
  }
354
- if (!body || body.ok !== true || !body.quote) fail("quote-refused", { step: "order-quote" });
364
+ // A NODE'S DENYLIST REFUSAL REACHES THE APP UNDER ITS OWN NEUTRAL SDK CODE, WITHOUT the `why` text and
365
+ // WITHOUT the address: the node names both (the maker's denylist and the address), and the interface shows
366
+ // the person its own neutral message. Every other refusal keeps its existing code (quote-refused etc.).
367
+ const refusedCode = res && res.body && typeof res.body.code === "string" ? res.body.code : null;
368
+ if (refusedCode === "denied-address") fail("address-denied", { step: "order-quote" });
369
+ if (!res || res.ok !== true || !res.body || res.body.ok !== true || !res.body.quote) {
370
+ fail("quote-refused", { step: "order-quote" });
371
+ }
372
+ const body = res.body;
355
373
  const quote = body.quote;
356
374
  // A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
357
375
  // not verifiable and is refused (its own code, so the interface can say which field is unbound).
package/src/swaps.mjs CHANGED
@@ -264,6 +264,10 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
264
264
  // adapter (send/receipt): windows and message signatures remain the interface's. A READY pass arrives here (as
265
265
  // www/js/evm/auth.js, signIn obtains it today), and the core merely presents it.
266
266
  auth = null,
267
+ // THE INTERFACE'S ADDRESS DENYLIST (issue #16), a Set of lower-cased addresses already assembled by the facade.
268
+ // The check on the ACTUAL sender is done in start() below, ONE place, right before the send - the wallet adapter
269
+ // only HANDS OUT the current account (`address()`), it does not carry the policy.
270
+ denylist = null,
267
271
  watch: watchOptions = null } = {}) {
268
272
  // WHAT READS THE CHAIN AT THE LOCK IS A SEAM, as in actions.mjs: by default the engine's read, from outside you can
269
273
  // substitute your own. Otherwise the lock step could not be checked beyond predicting the address - and "the address
@@ -757,6 +761,19 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
757
761
  if (!wallet || typeof wallet.send !== "function" || typeof wallet.receipt !== "function") {
758
762
  throw new SdkError("bad-input", { field: "wallet", missing: ["send", "receipt"], surface: "swaps.start", step: "lock" });
759
763
  }
764
+ // THE INTERFACE'S ADDRESS DENYLIST, ON THE ACTUAL SENDER (issue #16). The person may have switched accounts
765
+ // in the wallet, so what matters is the address the funding transaction will really be sent from AT THIS
766
+ // MOMENT, not `order.locker` checked earlier. The wallet adapter hands out the current account (the adapter's
767
+ // `address()` reads `eth_accounts[0]`); the comparison lives HERE, one place, right before the send - not
768
+ // inside the adapter. The refusal happens BEFORE any request: no transaction is signed or sent.
769
+ if (denylist) {
770
+ const sender = typeof wallet.address === "function" ? await wallet.address() : null;
771
+ if (typeof sender !== "string" || !sender) {
772
+ // A HALF-ADAPTER CANNOT BE APPLIED THE POLICY TO: silently skipping would make the list skippable.
773
+ throw new SdkError("bad-input", { field: "wallet", missing: ["address"], surface: "swaps.start", step: "lock" });
774
+ }
775
+ if (denylist.has(sender.toLowerCase())) throw new SdkError("address-denied", { step: "lock" });
776
+ }
760
777
  // THE LOCK TERMS ARE THE SAME AS THE ORDER'S, and are taken from the caller: exactly these fields enter the
761
778
  // context the DLEQ proof is bound to (orderContext.js, FIELDS). The deadlines and composition are checked by the
762
779
  // engine (lock.send -> escrow.orderTerms) BEFORE sending, not by our own arithmetic: the contract sets the rules.