@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 +28 -0
- package/README.md +4 -1
- package/index.d.ts +18 -0
- package/package.json +1 -1
- package/src/codes.mjs +5 -0
- package/src/index.mjs +16 -2
- package/src/quotes.mjs +22 -4
- package/src/swaps.mjs +17 -0
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|