@ninsei-labs/sdk 0.49.0 → 0.50.0

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,15 @@
1
+ ## 0.50.0 — 2026-10-21
2
+
3
+ A CLAIM IS SENT BY ITS CLAIMER, AND A SALE IS RANKED AFTER THE TIP.
4
+
5
+ - `actions.claim` compares the wallet's address with `request.claimer` or `expect.claimer` before it signs. A
6
+ third-party wallet, a missing claimer or a wallet without an address is a named `bad-input`; `claimSenderVerdict`
7
+ exposes the same rule. This is a deliberate tightening: a claim reveals the half to its sender.
8
+ - `readQuote` keeps the signed `claimTip`, and `rankOffers` selects a sale by `amount × rate − claimTip`, not by
9
+ the headline rate. A buy, or a request without an amount, still ranks by rate.
10
+ - `quotes.watch` uses that ranking for the chosen offer, so moving margin from the price into the tip no longer
11
+ wins a sale.
12
+
1
13
  ## 0.49.0 — 2026-10-21
2
14
 
3
15
  A ONE-KEY RECOVERY CAN WITHDRAW WITHOUT A FULL SCAN, AND A CLAIM TIP IS CHECKED.
package/index.d.ts CHANGED
@@ -507,6 +507,8 @@ export interface QuoteOffer {
507
507
  readonly currency: string | null;
508
508
  readonly rate: number;
509
509
  readonly rateQuote: number;
510
+ /** The claim tip in native-token wei; a sale is ranked by the payout AFTER it. */
511
+ readonly claimTip: string;
510
512
  readonly min: number | null;
511
513
  readonly max: number | null;
512
514
  readonly step: number | null;
@@ -1597,6 +1599,9 @@ export interface ActionsApi {
1597
1599
  export interface ActionsRequest {
1598
1600
  readonly escrow: string;
1599
1601
  readonly expect: Readonly<Record<string, unknown>>;
1602
+ /** The claimer whose half is revealed by `claim`; absent - `expect.claimer` is used. A claim without one is
1603
+ * refused: the package sends it only from the claimer's own wallet, never from a third-party sender. */
1604
+ readonly claimer?: string;
1600
1605
  readonly onStep?: (stage: string, detail?: string) => void;
1601
1606
  }
1602
1607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/actions.mjs CHANGED
@@ -25,6 +25,18 @@ const walletOrFail = (wallet) => {
25
25
  return wallet;
26
26
  };
27
27
 
28
+ // THE CLAIM'S HALF IS REVEALED BY WHOEVER SENDS IT. The package sends a claim only from the claimer's own wallet:
29
+ // a third-party sender would see the half before the claim is mined, and the only reason to use one is the tip -
30
+ // not a reason to hand the half to another party. A caller with no claimer is refused, not sent blind.
31
+ export function claimSenderVerdict({ sender = null, claimer = null } = {}) {
32
+ const from = typeof sender === "string" ? sender.trim().toLowerCase() : "";
33
+ const to = typeof claimer === "string" ? claimer.trim().toLowerCase() : "";
34
+ if (!to) return { ok: false, code: "bad-input", field: "claimer" };
35
+ if (!from) return { ok: false, code: "bad-input", field: "wallet.address" };
36
+ if (from !== to) return { ok: false, code: "bad-input", field: "wallet", why: "not-the-claimer" };
37
+ return { ok: true };
38
+ }
39
+
28
40
  const requestOrFail = (request, extra) => {
29
41
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
30
42
  const missing = missingFields(request, ["escrow", "expect", ...extra]);
@@ -59,6 +71,9 @@ export function createActions({ call = null } = {}) {
59
71
  requestOrFail(request, ["halfClaimer"]);
60
72
  walletOrFail(wallet);
61
73
  const { escrow, halfClaimer, expect, onStep } = request;
74
+ const sender = typeof wallet.address === "function" ? await wallet.address() : null;
75
+ const verdict = claimSenderVerdict({ sender, claimer: request.claimer || (expect && expect.claimer) });
76
+ if (!verdict.ok) throw new SdkError(verdict.code, { field: verdict.field, why: verdict.why, surface: "actions.claim" });
62
77
  return await withCodes("claim", () => flow.claimOrder({ escrow, halfClaimer, expect, onStep, deps: deps(wallet) }));
63
78
  },
64
79
  /** Refunding: it also reveals a half. The core passes the expectations ALWAYS, though the engine does not require them. */
package/src/quotes.mjs CHANGED
@@ -83,6 +83,13 @@ export function readQuote(pair, raw, owner = {}, data = {}) {
83
83
  currencyNetwork: q.currencyNetwork ? String(q.currencyNetwork) : null,
84
84
  rateQuote,
85
85
  rate,
86
+ // THE CLAIM TIP IS IN THE MAKER'S NATIVE TOKEN (wei). A sale is ranked by what the seller receives AFTER it;
87
+ // the value is kept as a string, because a wei amount does not fit a float reliably.
88
+ claimTip: (() => {
89
+ if (q.claimTip === undefined || q.claimTip === null || q.claimTip === "") return "0";
90
+ try { const n = BigInt(q.claimTip); return n < 0n ? "0" : n.toString(); }
91
+ catch { return "0"; }
92
+ })(),
86
93
  min: num(q.min),
87
94
  max: num(q.max),
88
95
  step: num(q.step),
@@ -104,6 +111,27 @@ export function readQuote(pair, raw, owner = {}, data = {}) {
104
111
  };
105
112
  }
106
113
 
114
+ /**
115
+ * THE BEST OFFER IS THE ONE THAT PAYS THE MOST, NOT THE ONE WITH THE HIGHEST RATE. On a sale the maker may move
116
+ * its margin into `claimTip`: the escrow pays that tip out of the seller's payout, so a higher rate with a larger
117
+ * tip can leave the seller with less. With no amount there is nothing to compare beyond the rate, and a buy has
118
+ * no tip. Native-token wei are converted at 18 decimals: a sale settles in the chain's native coin.
119
+ */
120
+ export function rankOffers(offers = [], { direction = "buy", amount = null } = {}) {
121
+ const list = Array.isArray(offers) ? offers.filter((offer) => offer && Number(offer.rate) > 0) : [];
122
+ const amountXmr = Number(amount);
123
+ const score = (offer) => {
124
+ const rate = Number(offer.rate);
125
+ if (direction !== "sell" || !(amountXmr > 0)) return rate;
126
+ let tip = 0;
127
+ try { tip = Number(BigInt(offer.claimTip || "0")) / 1e18; } catch { tip = 0; }
128
+ return amountXmr * rate - tip;
129
+ };
130
+ const ranked = list.map((offer) => ({ offer, net: score(offer) }))
131
+ .sort((a, b) => (b.net - a.net) || (Number(b.offer.rate) - Number(a.offer.rate)));
132
+ return { best: ranked.length ? ranked[0].offer : null, net: ranked.length ? ranked[0].net : null, ranked };
133
+ }
134
+
107
135
  /**
108
136
  * Parsing the book in one place. The envelope is accepted in three forms (a map by pairs, a list of rows, a list
109
137
  * of providers) and is brought to a single list: the screen must not know which form the answer came in.
@@ -201,7 +229,7 @@ const compactOffer = (o) => ({
201
229
  assetNetwork: o.assetNetwork, currencyNetwork: o.currencyNetwork, networkStated: o.networkStated,
202
230
  networksDerived: o.networksDerived === true, networksUnnamed: o.networksUnnamed === true,
203
231
  // THE REASON (in the provider's words), THE PACKAGE'S SIGNATURE AND KEY, THE AGE BY ITS OWN MARK.
204
- why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
232
+ claimTip: o.claimTip, why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
205
233
  networks: { asset: o.assetNetwork, currency: o.currencyNetwork, stated: o.networkStated },
206
234
  });
207
235
 
@@ -267,8 +295,11 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
267
295
  const asked = explicit ? Number(request.size) : size;
268
296
  const data = await http.json("/rate?amount=" + encodeURIComponent(asked) + "&side=" + encodeURIComponent(request.direction));
269
297
  const book = readBook(data, now());
270
- const offer = book.best;
271
298
  const amount = Number.isFinite(Number(request.amount)) ? Number(request.amount) : null;
299
+ // A SALE IS RANKED BY THE PAYOUT AFTER THE TIP; a buy has no tip. Without an amount the rate is the only
300
+ // comparable number, and the service's own best remains the fallback.
301
+ const ranked = rankOffers(book.offers, { direction: request.direction, amount });
302
+ const offer = ranked.best || book.best;
272
303
  // The size derived from the person's amount becomes the size of the NEXT request: the price does not depend
273
304
  // on volume, and the volume is needed by the provider to say whether it takes such a size. With a named size
274
305
  // there is nothing to derive - it stays what it was asked as.
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.49.0";
9
+ export const SDK_VERSION = "0.50.0";