@ninsei-labs/sdk 0.53.0 → 0.55.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 +41 -1
- package/LICENSE-POLICY.md +46 -0
- package/README.md +13 -5
- package/THIRD_PARTY_NOTICES.md +656 -0
- package/engine/evm/funding.js +37 -0
- package/index.d.ts +131 -6
- package/package.json +25 -3
- package/src/index.mjs +6 -0
- package/src/public/recovery.d.ts +14 -3
- package/src/public/recovery.mjs +1 -0
- package/src/public/relay.d.ts +77 -0
- package/src/public/relay.mjs +37 -0
- package/src/quotes.mjs +245 -1
- package/src/record.mjs +10 -0
- package/src/recovery-key.mjs +49 -9
- package/src/relayFeatures.mjs +206 -0
- package/src/relayFirmClient.mjs +86 -0
- package/src/relayQuote.mjs +209 -0
- package/src/relayRequest.mjs +188 -0
- package/src/relayTerms.mjs +145 -0
- package/src/relayView.mjs +110 -0
- package/src/swaps.mjs +203 -2
- package/src/sweep.mjs +10 -1
- package/src/version.mjs +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ONE HTTP HOP TO A RELAY FIRM QUOTE.
|
|
2
|
+
//
|
|
3
|
+
// The three crypto layers are already separate: features, request and answer. This module joins them to the
|
|
4
|
+
// wire: it builds the exact payload, sends it once, maps a refusal to a named code and verifies the answer
|
|
5
|
+
// before returning anything a caller could fund. The payload string is never re-serialised.
|
|
6
|
+
import { SdkError } from "./errors.mjs";
|
|
7
|
+
import { createHttp } from "./http.mjs";
|
|
8
|
+
import { buildFirmRequest } from "./relayRequest.mjs";
|
|
9
|
+
import { verifyFirmAnswer } from "./relayQuote.mjs";
|
|
10
|
+
|
|
11
|
+
const REFUSAL_CODES = new Set([
|
|
12
|
+
"compliance",
|
|
13
|
+
"no_liquidity",
|
|
14
|
+
"size",
|
|
15
|
+
"paused",
|
|
16
|
+
"unavailable",
|
|
17
|
+
"bad_request",
|
|
18
|
+
"price_changed",
|
|
19
|
+
]);
|
|
20
|
+
const sameRelay = (a, b) => String(a === undefined || a === null ? "" : a) === String(b === undefined || b === null ? "" : b);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Request a firm quote from a relay.
|
|
24
|
+
*
|
|
25
|
+
* `relayUrl` is the relay origin (for example `https://relay.example`), `partner` is the optional partner
|
|
26
|
+
* label and every other option is passed to `buildFirmRequest`. A transport error is
|
|
27
|
+
* `server-unavailable`; a relay refusal keeps its code in `quote-refused`. A timeout is not retried here:
|
|
28
|
+
* the salt is part of the signed terms, so a retry needs the caller's decision and a fresh proof.
|
|
29
|
+
*/
|
|
30
|
+
export async function requestFirmQuote({
|
|
31
|
+
relayUrl,
|
|
32
|
+
partner = null,
|
|
33
|
+
fetchImpl = null,
|
|
34
|
+
timeoutMs = 20_000,
|
|
35
|
+
...request
|
|
36
|
+
} = {}) {
|
|
37
|
+
const base = String(relayUrl === undefined || relayUrl === null ? "" : relayUrl).trim().replace(/\/+$/, "");
|
|
38
|
+
if (!base) throw new SdkError("bad-input", { field: "relayUrl" });
|
|
39
|
+
|
|
40
|
+
const built = await buildFirmRequest(request);
|
|
41
|
+
const body = JSON.stringify({
|
|
42
|
+
version: 1,
|
|
43
|
+
payload: built.payload,
|
|
44
|
+
...(partner ? { partner: String(partner) } : {}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const http = createHttp({ apiBase: base, fetchImpl, timeoutMs });
|
|
48
|
+
const response = await http.tryJson("/v1/quote", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const code = response.body && response.body.code ? String(response.body.code) : null;
|
|
56
|
+
if (code && REFUSAL_CODES.has(code)) {
|
|
57
|
+
throw new SdkError("quote-refused", { step: "relay", code, status: response.status });
|
|
58
|
+
}
|
|
59
|
+
throw new SdkError("server-unavailable", { step: "relay", status: response.status });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const answer = response.body && response.body.answer;
|
|
63
|
+
if (!response.body || Number(response.body.version) !== 1 || !answer) {
|
|
64
|
+
throw new SdkError("quote-refused", { step: "answer", why: "envelope" });
|
|
65
|
+
}
|
|
66
|
+
if (typeof response.body.relay !== "string" || !response.body.relay
|
|
67
|
+
|| !response.body.forwarded || response.body.forwarded.relayId !== response.body.relay) {
|
|
68
|
+
throw new SdkError("quote-refused", { step: "answer", why: "relay" });
|
|
69
|
+
}
|
|
70
|
+
if (!sameRelay(answer.relayId, response.body.relay)) {
|
|
71
|
+
throw new SdkError("quote-refused", { step: "answer", why: "relay" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const verified = verifyFirmAnswer({
|
|
75
|
+
answer,
|
|
76
|
+
features: request.features,
|
|
77
|
+
request: built,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
...verified,
|
|
81
|
+
relay: response.body.relay ?? null,
|
|
82
|
+
offered: response.body.offered ?? null,
|
|
83
|
+
forwarded: response.body.forwarded ?? null,
|
|
84
|
+
answer,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// THE SIGNED ANSWER A RELAY RETURNS.
|
|
2
|
+
//
|
|
3
|
+
// The relay's outer `quote` frame is signed by the maker's quote key in the relay domain. Its `payload` is a
|
|
4
|
+
// JSON string with the factory, the signed `OrderQuote`, the node half and both view points. This module checks
|
|
5
|
+
// both signatures and binds the answer to the request terms before the caller can fund anything.
|
|
6
|
+
import { SdkError } from "./errors.mjs";
|
|
7
|
+
import { keccak256, secp256k1 } from "./primitives.mjs";
|
|
8
|
+
import { recoverOrderQuoteKey } from "./quoteEip712.mjs";
|
|
9
|
+
import * as engine from "./engine.mjs";
|
|
10
|
+
|
|
11
|
+
export const RELAY_QUOTE_TYPE =
|
|
12
|
+
"Quote(string relayId,address provider,bytes32 session,bytes32 requestId,uint64 counter,bytes32 requestHash,string payload,uint64 time)";
|
|
13
|
+
|
|
14
|
+
const DOMAIN_TYPE = "EIP712Domain(string name,string version)";
|
|
15
|
+
const DOMAIN_NAME = "Ninsei Relay";
|
|
16
|
+
const DOMAIN_VERSION = "1";
|
|
17
|
+
|
|
18
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
19
|
+
const concat = (...arrays) => {
|
|
20
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
21
|
+
const out = new Uint8Array(total);
|
|
22
|
+
let at = 0;
|
|
23
|
+
for (const array of arrays) { out.set(array, at); at += array.length; }
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
const hexBytes = (value, field, width) => {
|
|
27
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
28
|
+
if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) throw new SdkError("bad-input", { field });
|
|
29
|
+
const out = new Uint8Array(width);
|
|
30
|
+
for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
const wordAddress = (value, field) => {
|
|
34
|
+
const out = new Uint8Array(32);
|
|
35
|
+
out.set(hexBytes(value, field, 20), 12);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
const wordBytes32 = (value, field) => hexBytes(value, field, 32);
|
|
39
|
+
const wordUint = (value, field) => {
|
|
40
|
+
let n = null;
|
|
41
|
+
try { n = BigInt(value); } catch { n = null; }
|
|
42
|
+
if (n === null || n < 0n) throw new SdkError("bad-input", { field });
|
|
43
|
+
const out = new Uint8Array(32);
|
|
44
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
const wordString = (value) => keccak256(utf8(value === undefined || value === null ? "" : value));
|
|
48
|
+
|
|
49
|
+
export function relayQuoteStructHash(answer) {
|
|
50
|
+
if (!answer || typeof answer !== "object") throw new SdkError("bad-input", { field: "answer" });
|
|
51
|
+
return keccak256(concat(
|
|
52
|
+
keccak256(utf8(RELAY_QUOTE_TYPE)),
|
|
53
|
+
wordString(answer.relayId),
|
|
54
|
+
wordAddress(answer.provider, "answer.provider"),
|
|
55
|
+
wordBytes32(answer.session, "answer.session"),
|
|
56
|
+
wordBytes32(answer.requestId, "answer.requestId"),
|
|
57
|
+
wordUint(answer.counter, "answer.counter"),
|
|
58
|
+
wordBytes32(answer.requestHash, "answer.requestHash"),
|
|
59
|
+
wordString(answer.payload),
|
|
60
|
+
wordUint(answer.time, "answer.time"),
|
|
61
|
+
));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function relayQuoteDomainSeparator() {
|
|
65
|
+
return keccak256(concat(
|
|
66
|
+
keccak256(utf8(DOMAIN_TYPE)),
|
|
67
|
+
keccak256(utf8(DOMAIN_NAME)),
|
|
68
|
+
keccak256(utf8(DOMAIN_VERSION)),
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function relayQuoteDigest(answer) {
|
|
73
|
+
return keccak256(concat(Uint8Array.from([0x19, 0x01]), relayQuoteDomainSeparator(), relayQuoteStructHash(answer)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hex = (bytes) => {
|
|
77
|
+
let out = "0x";
|
|
78
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
function recoverAddress(digest, signature) {
|
|
82
|
+
const sig = String(signature || "").replace(/^0x/, "");
|
|
83
|
+
if (!/^[0-9a-fA-F]{130}$/.test(sig)) return null;
|
|
84
|
+
let v = parseInt(sig.slice(128, 130), 16);
|
|
85
|
+
if (v >= 27) v -= 27;
|
|
86
|
+
if (v !== 0 && v !== 1) return null;
|
|
87
|
+
try {
|
|
88
|
+
const publicKey = secp256k1.Signature.fromBytes(concat(
|
|
89
|
+
hexBytes(sig.slice(0, 64), "signature", 32),
|
|
90
|
+
hexBytes(sig.slice(64, 128), "signature", 32),
|
|
91
|
+
)).addRecoveryBit(v).recoverPublicKey(digest);
|
|
92
|
+
return hex(keccak256(publicKey.toBytes(false).slice(1)).slice(12));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The outer frame: signed by the quote key in the relay domain.
|
|
99
|
+
export function verifyRelayQuoteFrame(answer, quoteKey) {
|
|
100
|
+
if (!answer || typeof answer !== "object" || answer.type !== "quote" || Number(answer.version) !== 1) {
|
|
101
|
+
throw new SdkError("quote-refused", { step: "answer", why: "frame" });
|
|
102
|
+
}
|
|
103
|
+
const recovered = recoverAddress(relayQuoteDigest(answer), answer.signature);
|
|
104
|
+
if (recovered === null || recovered !== String(quoteKey || "").toLowerCase()) {
|
|
105
|
+
throw new SdkError("quote-refused", { step: "answer", why: "signature" });
|
|
106
|
+
}
|
|
107
|
+
return answer;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ZERO = "0x" + "00".repeat(20);
|
|
111
|
+
const lower = (value) => String(value === undefined || value === null ? "" : value).toLowerCase();
|
|
112
|
+
const same = (a, b) => lower(a) === lower(b);
|
|
113
|
+
|
|
114
|
+
function checkOrderQuote(quote, terms, nowSec) {
|
|
115
|
+
const order = terms.order;
|
|
116
|
+
if (!quote || typeof quote !== "object") throw new SdkError("quote-refused", { step: "answer", why: "quote" });
|
|
117
|
+
const pairs = [
|
|
118
|
+
["chainId", String(order.chainId)],
|
|
119
|
+
["factory", order.factory],
|
|
120
|
+
["locker", order.locker],
|
|
121
|
+
["claimer", order.claimer],
|
|
122
|
+
["amount", String(order.amount)],
|
|
123
|
+
["readyBy", String(order.readyBy)],
|
|
124
|
+
["t1", String(order.t1)],
|
|
125
|
+
["salt", order.salt],
|
|
126
|
+
["xmrAmount", String(terms.xmrAmount)],
|
|
127
|
+
["claimTip", String(terms.claimTip)],
|
|
128
|
+
["provider", terms.maker],
|
|
129
|
+
["registry", terms.registry],
|
|
130
|
+
];
|
|
131
|
+
for (const [field, want] of pairs) {
|
|
132
|
+
if (!same(quote[field], want)) throw new SdkError("quote-refused", { step: "answer", why: "field", field });
|
|
133
|
+
}
|
|
134
|
+
if (same(quote.feeRecipient, ZERO)) {
|
|
135
|
+
throw new SdkError("quote-refused", { step: "answer", why: "fee-recipient" });
|
|
136
|
+
}
|
|
137
|
+
const bps = Number(quote.feeBps);
|
|
138
|
+
let fee = null;
|
|
139
|
+
let amount = null;
|
|
140
|
+
try { fee = BigInt(String(quote.fee)); amount = BigInt(String(order.amount)); }
|
|
141
|
+
catch { throw new SdkError("quote-refused", { step: "answer", why: "fee" }); }
|
|
142
|
+
if (!Number.isSafeInteger(bps) || bps < 0 || bps > 200) {
|
|
143
|
+
throw new SdkError("quote-refused", { step: "answer", why: "fee" });
|
|
144
|
+
}
|
|
145
|
+
if (fee !== (amount * BigInt(bps)) / 10000n) throw new SdkError("quote-refused", { step: "answer", why: "fee" });
|
|
146
|
+
const validUntil = Number(quote.validUntil);
|
|
147
|
+
if (!Number.isSafeInteger(validUntil) || validUntil <= nowSec) {
|
|
148
|
+
throw new SdkError("quote-expired", { step: "answer", validUntil: quote.validUntil });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Verify an answer against the request terms and the verified features. `request` is the result of
|
|
154
|
+
* `buildFirmRequest`: it carries the normalised terms and both view points.
|
|
155
|
+
*/
|
|
156
|
+
export function verifyFirmAnswer({ answer, features, request, now = null } = {}) {
|
|
157
|
+
if (!request || !request.terms) throw new SdkError("bad-input", { field: "request" });
|
|
158
|
+
verifyRelayQuoteFrame(answer, features && features.quoteKey);
|
|
159
|
+
if (!answer.relayId || !same(answer.relayId, features && features.relayId)) {
|
|
160
|
+
throw new SdkError("quote-refused", { step: "answer", why: "relay" });
|
|
161
|
+
}
|
|
162
|
+
let payload = null;
|
|
163
|
+
try { payload = JSON.parse(answer.payload); } catch { throw new SdkError("quote-refused", { step: "answer", why: "payload" }); }
|
|
164
|
+
if (!payload || typeof payload !== "object" || !payload.quote) {
|
|
165
|
+
throw new SdkError("quote-refused", { step: "answer", why: "payload" });
|
|
166
|
+
}
|
|
167
|
+
if (payload.contextVersion !== "ninsei-order-v3" || Number(payload.quoteVersion) !== 9) {
|
|
168
|
+
throw new SdkError("quote-refused", { step: "answer", why: "version" });
|
|
169
|
+
}
|
|
170
|
+
if (!same(payload.factory, request.terms.order.factory)) {
|
|
171
|
+
throw new SdkError("quote-refused", { step: "answer", why: "factory" });
|
|
172
|
+
}
|
|
173
|
+
if (!same(answer.provider, request.terms.maker)) {
|
|
174
|
+
throw new SdkError("quote-refused", { step: "answer", why: "provider" });
|
|
175
|
+
}
|
|
176
|
+
const quote = { ...payload.quote, factory: request.terms.order.factory };
|
|
177
|
+
if (!quote.quoteSignature) quote.quoteSignature = payload.quoteSignature;
|
|
178
|
+
let context = null;
|
|
179
|
+
try { context = engine.orderContext.orderContextString(request.terms.order); } catch { context = null; }
|
|
180
|
+
if (!context || payload.context !== context) throw new SdkError("quote-refused", { step: "context", why: "other-terms" });
|
|
181
|
+
const recovered = recoverOrderQuoteKey({ ...quote, signature: payload.quoteSignature });
|
|
182
|
+
if (recovered === null) throw new SdkError("quote-refused", { step: "signature", why: "invalid" });
|
|
183
|
+
if (!same(recovered, quote.quoteKey)) throw new SdkError("quote-refused", { step: "signature", why: "key" });
|
|
184
|
+
const nowSec = Math.floor((now === null ? Date.now() : now) / 1000);
|
|
185
|
+
checkOrderQuote(quote, request.terms, nowSec);
|
|
186
|
+
const side = request.terms.side;
|
|
187
|
+
const lockerPoint = side === "ask" ? request.edViewPointClient : request.edViewPointNode;
|
|
188
|
+
const claimerPoint = side === "ask" ? request.edViewPointNode : request.edViewPointClient;
|
|
189
|
+
if (!same(payload.edViewPointLocker, lockerPoint) || !same(payload.edViewPointClaimer, claimerPoint)) {
|
|
190
|
+
throw new SdkError("quote-refused", { step: "answer", why: "view-points" });
|
|
191
|
+
}
|
|
192
|
+
const nodeHalf = payload.nodeHalf;
|
|
193
|
+
if (!nodeHalf || typeof nodeHalf !== "object" || !nodeHalf.proof) {
|
|
194
|
+
throw new SdkError("quote-refused", { step: "answer", why: "node-half" });
|
|
195
|
+
}
|
|
196
|
+
if (side === "ask") {
|
|
197
|
+
if (!same(nodeHalf.commitHalf, quote.commitHalfClaimer) || !same(nodeHalf.edPoint, quote.edPointClaimer)) {
|
|
198
|
+
throw new SdkError("quote-refused", { step: "answer", why: "node-half" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
quote,
|
|
203
|
+
orderQuote: { ...quote, factory: payload.factory, signature: payload.quoteSignature },
|
|
204
|
+
nodeHalf,
|
|
205
|
+
edViewPointLocker: payload.edViewPointLocker,
|
|
206
|
+
edViewPointClaimer: payload.edViewPointClaimer,
|
|
207
|
+
context,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ONE FIRM REQUEST: THE TERMS, THE CLIENT HALF AND THE ENCRYPTED VIEW MATERIAL.
|
|
2
|
+
//
|
|
3
|
+
// The relay signs the payload as the exact UTF-8 string it forwards, so this module serialises it once. Every
|
|
4
|
+
// field that the node or the relay checks is taken from the caller's terms or from the signed features; nothing
|
|
5
|
+
// is filled in from a second source.
|
|
6
|
+
//
|
|
7
|
+
// The builder does not create the client half: the caller has already built one order side and proved it in
|
|
8
|
+
// this context. It normalises the proof's spellings and refuses a half whose point is not the one in its proof.
|
|
9
|
+
import { SdkError } from "./errors.mjs";
|
|
10
|
+
import { ed25519, keccak256 } from "./primitives.mjs";
|
|
11
|
+
import { verifyNodeFeatures } from "./relayFeatures.mjs";
|
|
12
|
+
import { relayViewInfo, sealViewEnvelope } from "./relayView.mjs";
|
|
13
|
+
import { firmTermsDigest, firmTermsMissingFields } from "./relayTerms.mjs";
|
|
14
|
+
|
|
15
|
+
const hexToBytes = (value, field, width) => {
|
|
16
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
17
|
+
if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) {
|
|
18
|
+
throw new SdkError("bad-input", { field });
|
|
19
|
+
}
|
|
20
|
+
const out = new Uint8Array(width);
|
|
21
|
+
for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
const hex = (bytes) => {
|
|
25
|
+
let out = "0x";
|
|
26
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const normHex = (value, width, field) => hex(hexToBytes(value, field, width));
|
|
30
|
+
const scalar = (value, field) => {
|
|
31
|
+
let n = null;
|
|
32
|
+
try { n = BigInt(value); } catch { n = null; }
|
|
33
|
+
if (n === null || n <= 0n || n >= ed25519.Point.Fn.ORDER) throw new SdkError("bad-input", { field });
|
|
34
|
+
return n;
|
|
35
|
+
};
|
|
36
|
+
const scalarBytes = (value, field) => {
|
|
37
|
+
let n = scalar(value, field);
|
|
38
|
+
const out = new Uint8Array(32);
|
|
39
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
const pointOf = (value, field) => hex(ed25519.Point.BASE.multiply(scalar(value, field)).toBytes());
|
|
43
|
+
const assertPoint = (value, field) => {
|
|
44
|
+
const bytes = hexToBytes(value, field, 32);
|
|
45
|
+
try { ed25519.Point.fromHex(hex(bytes).slice(2)); } catch { throw new SdkError("bad-input", { field }); }
|
|
46
|
+
return hex(bytes);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const PROOF_FIELDS = ["XA", "XB", "proofs"];
|
|
50
|
+
function normaliseProof(proof) {
|
|
51
|
+
if (!proof || typeof proof !== "object") throw new SdkError("bad-input", { field: "clientHalf.proof" });
|
|
52
|
+
for (const field of PROOF_FIELDS) {
|
|
53
|
+
if (proof[field] === undefined || proof[field] === null || proof[field] === "") {
|
|
54
|
+
throw new SdkError("bad-input", { field: "clientHalf.proof." + field });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(proof.proofs) || proof.proofs.length !== 253) {
|
|
58
|
+
throw new SdkError("bad-input", { field: "clientHalf.proof.proofs" });
|
|
59
|
+
}
|
|
60
|
+
const xa = normHex(proof.XA, 33, "clientHalf.proof.XA");
|
|
61
|
+
const xb = normHex(proof.XB, 32, "clientHalf.proof.XB");
|
|
62
|
+
const proofs = proof.proofs.map((entry, index) => {
|
|
63
|
+
if (!entry || typeof entry !== "object" || !entry.sig) {
|
|
64
|
+
throw new SdkError("bad-input", { field: "clientHalf.proof.proofs." + index });
|
|
65
|
+
}
|
|
66
|
+
const sig = {};
|
|
67
|
+
for (const field of ["eA", "eB", "a0", "a1", "b0", "b1"]) {
|
|
68
|
+
sig[field] = normHex(entry.sig[field], 32, "clientHalf.proof.proofs." + index + ".sig." + field);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
cA: normHex(entry.cA, 33, "clientHalf.proof.proofs." + index + ".cA"),
|
|
72
|
+
cB: normHex(entry.cB, 32, "clientHalf.proof.proofs." + index + ".cB"),
|
|
73
|
+
sig,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
return { XA: xa, XB: xb, proofs };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the exact request payload.
|
|
81
|
+
*
|
|
82
|
+
* `features` is the signed public object the relay forwarded; `terms` is the client's request terms (its
|
|
83
|
+
* `featuresHash` may be omitted and is filled from the verified features). `clientHalf` is
|
|
84
|
+
* `{commitHalf, edPoint, proof}`; `nodeViewScalar` and `clientViewScalar` are the two view halves the client
|
|
85
|
+
* chose. On ask only the node scalar is sent; on bid both.
|
|
86
|
+
*/
|
|
87
|
+
export async function buildFirmRequest({
|
|
88
|
+
features, terms, clientHalf, edViewPointClient, edViewPointNode,
|
|
89
|
+
nodeViewScalar, clientViewScalar = null, side = null,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const verdict = verifyNodeFeatures({ features, terms });
|
|
92
|
+
const requestSide = String(side || terms?.side || "");
|
|
93
|
+
if (requestSide !== "ask" && requestSide !== "bid") throw new SdkError("bad-input", { field: "side" });
|
|
94
|
+
if (terms?.side !== undefined && terms.side !== requestSide) throw new SdkError("bad-input", { field: "terms.side" });
|
|
95
|
+
if (!terms || typeof terms !== "object" || !terms.order || typeof terms.order !== "object") {
|
|
96
|
+
throw new SdkError("bad-input", { field: "terms" });
|
|
97
|
+
}
|
|
98
|
+
if (terms.featuresHash !== undefined && terms.featuresHash !== null && terms.featuresHash !== ""
|
|
99
|
+
&& String(terms.featuresHash).toLowerCase() !== verdict.featuresHash) {
|
|
100
|
+
throw new SdkError("quote-refused", { step: "features", why: "hash" });
|
|
101
|
+
}
|
|
102
|
+
const order = terms.order;
|
|
103
|
+
const orderFields = ["chainId", "factory", "locker", "claimer", "amount", "readyBy", "t1", "salt"];
|
|
104
|
+
for (const field of orderFields) {
|
|
105
|
+
if (order[field] === undefined || order[field] === null || order[field] === "") {
|
|
106
|
+
throw new SdkError("bad-input", { field: "terms.order." + field });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (String(order.chainId) !== String(terms.market || "").split(":")[0]) {
|
|
110
|
+
throw new SdkError("bad-input", { field: "terms.market" });
|
|
111
|
+
}
|
|
112
|
+
const half = clientHalf || {};
|
|
113
|
+
const commitHalf = normHex(half.commitHalf, 32, "clientHalf.commitHalf");
|
|
114
|
+
const point = normHex(half.edPoint, 32, "clientHalf.edPoint");
|
|
115
|
+
const proof = normaliseProof(half.proof);
|
|
116
|
+
if (proof.XB !== point) throw new SdkError("bad-input", { field: "clientHalf.edPoint" });
|
|
117
|
+
const maker = normHex(terms.maker, 20, "terms.maker");
|
|
118
|
+
const locker = normHex(order.locker, 20, "terms.order.locker");
|
|
119
|
+
const claimer = normHex(order.claimer, 20, "terms.order.claimer");
|
|
120
|
+
if (locker === claimer) throw new SdkError("bad-input", { field: "terms.order.roles" });
|
|
121
|
+
if (requestSide === "ask") {
|
|
122
|
+
if (claimer !== maker) throw new SdkError("bad-input", { field: "terms.order.claimer" });
|
|
123
|
+
if (String(terms.claimTip) !== "0") throw new SdkError("bad-input", { field: "terms.claimTip" });
|
|
124
|
+
} else if (locker !== maker) {
|
|
125
|
+
throw new SdkError("bad-input", { field: "terms.order.locker" });
|
|
126
|
+
}
|
|
127
|
+
const clientViewPoint = assertPoint(edViewPointClient, "edViewPointClient");
|
|
128
|
+
const nodeViewPoint = assertPoint(edViewPointNode, "edViewPointNode");
|
|
129
|
+
const nodeScalar = scalarBytes(nodeViewScalar, "nodeViewScalar");
|
|
130
|
+
if (pointOf(nodeViewScalar, "nodeViewScalar").toLowerCase() !== nodeViewPoint.toLowerCase()) {
|
|
131
|
+
throw new SdkError("bad-input", { field: "edViewPointNode" });
|
|
132
|
+
}
|
|
133
|
+
let clientScalar = null;
|
|
134
|
+
if (requestSide === "bid") {
|
|
135
|
+
clientScalar = scalarBytes(clientViewScalar, "clientViewScalar");
|
|
136
|
+
if (pointOf(clientViewScalar, "clientViewScalar").toLowerCase() !== clientViewPoint.toLowerCase()) {
|
|
137
|
+
throw new SdkError("bad-input", { field: "edViewPointClient" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const requestTerms = {
|
|
141
|
+
market: String(terms.market),
|
|
142
|
+
side: requestSide,
|
|
143
|
+
maker: normHex(terms.maker, 20, "terms.maker"),
|
|
144
|
+
registry: normHex(terms.registry, 20, "terms.registry"),
|
|
145
|
+
featuresHash: verdict.featuresHash,
|
|
146
|
+
xmrNetwork: String(terms.xmrNetwork),
|
|
147
|
+
order: {
|
|
148
|
+
chainId: String(order.chainId),
|
|
149
|
+
factory: normHex(order.factory, 20, "terms.order.factory"),
|
|
150
|
+
locker: normHex(order.locker, 20, "terms.order.locker"),
|
|
151
|
+
claimer: normHex(order.claimer, 20, "terms.order.claimer"),
|
|
152
|
+
amount: String(order.amount),
|
|
153
|
+
readyBy: Number(order.readyBy),
|
|
154
|
+
t1: Number(order.t1),
|
|
155
|
+
salt: normHex(order.salt, 32, "terms.order.salt"),
|
|
156
|
+
},
|
|
157
|
+
xmrAmount: String(terms.xmrAmount),
|
|
158
|
+
claimTip: String(terms.claimTip),
|
|
159
|
+
};
|
|
160
|
+
const missing = firmTermsMissingFields(requestTerms);
|
|
161
|
+
if (missing.length) throw new SdkError("bad-input", { field: "terms", missing });
|
|
162
|
+
const termsDigest = hex(firmTermsDigest({ crypto: { keccak256 }, terms: requestTerms }));
|
|
163
|
+
const info = relayViewInfo({
|
|
164
|
+
termsDigest,
|
|
165
|
+
commitHalf,
|
|
166
|
+
edPoint: point,
|
|
167
|
+
edViewPointClient: clientViewPoint,
|
|
168
|
+
edViewPointNode: nodeViewPoint,
|
|
169
|
+
});
|
|
170
|
+
const plaintext = new Uint8Array(requestSide === "ask" ? 32 : 64);
|
|
171
|
+
plaintext.set(nodeScalar, 0);
|
|
172
|
+
if (requestSide === "bid") plaintext.set(clientScalar, 32);
|
|
173
|
+
const viewEnvelope = await sealViewEnvelope({ hpkeKey: verdict.hpkeKey, info, plaintext });
|
|
174
|
+
const payload = JSON.stringify({
|
|
175
|
+
terms: requestTerms,
|
|
176
|
+
clientHalf: { commitHalf, edPoint: point, proof },
|
|
177
|
+
edViewPointClient: clientViewPoint,
|
|
178
|
+
edViewPointNode: nodeViewPoint,
|
|
179
|
+
viewEnvelope,
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
payload,
|
|
183
|
+
terms: requestTerms,
|
|
184
|
+
viewEnvelope,
|
|
185
|
+
edViewPointClient: clientViewPoint,
|
|
186
|
+
edViewPointNode: nodeViewPoint,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// THE EIP-712 TERMS A RELAY FIRM REQUEST SIGNS.
|
|
2
|
+
//
|
|
3
|
+
// The relay forwards the client's exact payload as a string and signs its UTF-8 bytes. The same terms are
|
|
4
|
+
// also the HPKE context: `relayViewInfo` takes this digest, so a ciphertext is bound to the amounts, roles,
|
|
5
|
+
// deadlines and salt in the request rather than to a separately supplied label.
|
|
6
|
+
//
|
|
7
|
+
// The domain is the relay domain, not the factory domain: `{name: "Ninsei Relay", version: "1"}` has no
|
|
8
|
+
// chainId and no verifyingContract. The inner `OrderQuote` keeps its own factory domain; the two signatures
|
|
9
|
+
// are deliberately separate.
|
|
10
|
+
//
|
|
11
|
+
// The type strings and field order are wire format. A rearrangement produces another digest, and the node
|
|
12
|
+
// refuses the request before it decrypts anything.
|
|
13
|
+
import { SdkError } from "./errors.mjs";
|
|
14
|
+
|
|
15
|
+
export const FIRM_DOMAIN_NAME = "Ninsei Relay";
|
|
16
|
+
export const FIRM_DOMAIN_VERSION = "1";
|
|
17
|
+
const DOMAIN_TYPE_PARTS = ["EIP712Domain(", "string name,", "string version)"];
|
|
18
|
+
export const FIRM_DOMAIN_TYPE = DOMAIN_TYPE_PARTS.join("");
|
|
19
|
+
|
|
20
|
+
export const FIRM_ORDER_FIELDS = [
|
|
21
|
+
["chainId", "uint256"],
|
|
22
|
+
["factory", "address"],
|
|
23
|
+
["locker", "address"],
|
|
24
|
+
["claimer", "address"],
|
|
25
|
+
["amount", "uint256"],
|
|
26
|
+
["readyBy", "uint64"],
|
|
27
|
+
["t1", "uint64"],
|
|
28
|
+
["salt", "bytes32"],
|
|
29
|
+
];
|
|
30
|
+
export const FIRM_ORDER_TYPE = "FirmOrder(" + FIRM_ORDER_FIELDS.map(([name, type]) => type + " " + name).join(",") + ")";
|
|
31
|
+
|
|
32
|
+
export const FIRM_TERMS_FIELDS = [
|
|
33
|
+
["market", "string"],
|
|
34
|
+
["side", "string"],
|
|
35
|
+
["maker", "address"],
|
|
36
|
+
["registry", "address"],
|
|
37
|
+
["featuresHash", "bytes32"],
|
|
38
|
+
["xmrNetwork", "string"],
|
|
39
|
+
["order", "FirmOrder"],
|
|
40
|
+
["xmrAmount", "uint256"],
|
|
41
|
+
["claimTip", "uint256"],
|
|
42
|
+
];
|
|
43
|
+
// EIP-712 appends every referenced struct type to the primary type, sorted by name. `FirmOrder` is the only
|
|
44
|
+
// one, so the type hash covers both strings.
|
|
45
|
+
export const FIRM_TERMS_TYPE = "FirmTerms(" + FIRM_TERMS_FIELDS.map(([name, type]) => type + " " + name).join(",") + ")" + FIRM_ORDER_TYPE;
|
|
46
|
+
|
|
47
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
48
|
+
const concat = (...arrays) => {
|
|
49
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
50
|
+
const out = new Uint8Array(total);
|
|
51
|
+
let at = 0;
|
|
52
|
+
for (const array of arrays) { out.set(array, at); at += array.length; }
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
const hexToBytes = (value, field) => {
|
|
56
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
57
|
+
if (!/^[0-9a-fA-F]*$/.test(text) || text.length % 2) throw new SdkError("bad-input", { field });
|
|
58
|
+
const out = new Uint8Array(text.length / 2);
|
|
59
|
+
for (let i = 0; i < out.length; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const number = (value, field, bits) => {
|
|
63
|
+
let n = null;
|
|
64
|
+
try { n = BigInt(String(value === undefined || value === null ? "" : value).trim()); } catch { n = null; }
|
|
65
|
+
if (n === null || n < 0n || n >= (1n << BigInt(bits))) throw new SdkError("bad-input", { field });
|
|
66
|
+
const out = new Uint8Array(32);
|
|
67
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
const address = (value, field) => {
|
|
71
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
72
|
+
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(text)) throw new SdkError("bad-input", { field });
|
|
73
|
+
const out = new Uint8Array(32);
|
|
74
|
+
out.set(hexToBytes(text.replace(/^0x/, ""), field), 12);
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const bytes32 = (value, field) => {
|
|
78
|
+
const bytes = hexToBytes(value, field);
|
|
79
|
+
if (bytes.length !== 32) throw new SdkError("bad-input", { field });
|
|
80
|
+
return bytes;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const encodeField = (crypto, type, value, field) => {
|
|
84
|
+
if (type === "string") return crypto.keccak256(utf8(value === undefined || value === null ? "" : value));
|
|
85
|
+
if (type === "address") return address(value, field);
|
|
86
|
+
if (type === "bytes32") return bytes32(value, field);
|
|
87
|
+
if (type === "uint64") return number(value, field, 64);
|
|
88
|
+
if (type === "uint256") return number(value, field, 256);
|
|
89
|
+
throw new SdkError("bad-input", { field });
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const structHash = (crypto, typeString, fields, value, prefix) => {
|
|
93
|
+
const parts = [crypto.keccak256(utf8(typeString))];
|
|
94
|
+
for (const [field, type] of fields) {
|
|
95
|
+
if (type === "FirmOrder") {
|
|
96
|
+
const order = value && value.order;
|
|
97
|
+
if (!order || typeof order !== "object") throw new SdkError("bad-input", { field: prefix + ".order" });
|
|
98
|
+
parts.push(firmOrderHash(crypto, order));
|
|
99
|
+
} else {
|
|
100
|
+
parts.push(encodeField(crypto, type, value ? value[field] : undefined, prefix + "." + field));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return crypto.keccak256(concat(...parts));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function firmOrderHash(crypto, order) {
|
|
107
|
+
return structHash(crypto, FIRM_ORDER_TYPE, FIRM_ORDER_FIELDS, order, "terms.order");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function firmTermsDomainSeparator(crypto) {
|
|
111
|
+
return crypto.keccak256(concat(
|
|
112
|
+
crypto.keccak256(utf8(FIRM_DOMAIN_TYPE)),
|
|
113
|
+
crypto.keccak256(utf8(FIRM_DOMAIN_NAME)),
|
|
114
|
+
crypto.keccak256(utf8(FIRM_DOMAIN_VERSION)),
|
|
115
|
+
));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function firmTermsStructHash({ crypto, terms } = {}) {
|
|
119
|
+
if (!crypto || typeof crypto.keccak256 !== "function") throw new SdkError("bad-input", { field: "crypto" });
|
|
120
|
+
return structHash(crypto, FIRM_TERMS_TYPE, FIRM_TERMS_FIELDS, terms, "terms");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function firmTermsDigest({ crypto, terms } = {}) {
|
|
124
|
+
if (!crypto || typeof crypto.keccak256 !== "function") throw new SdkError("bad-input", { field: "crypto" });
|
|
125
|
+
return crypto.keccak256(concat(Uint8Array.from([0x19, 0x01]), firmTermsDomainSeparator(crypto), firmTermsStructHash({ crypto, terms })));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Absent fields are named, not treated as zero. The relay client builds a request only from a complete set.
|
|
129
|
+
export function firmTermsMissingFields(terms) {
|
|
130
|
+
const missing = [];
|
|
131
|
+
if (!terms || typeof terms !== "object") return ["terms"];
|
|
132
|
+
for (const [name] of FIRM_TERMS_FIELDS) {
|
|
133
|
+
if (name === "order") {
|
|
134
|
+
if (!terms.order || typeof terms.order !== "object") missing.push("order");
|
|
135
|
+
else for (const [field] of FIRM_ORDER_FIELDS) {
|
|
136
|
+
const value = terms.order[field];
|
|
137
|
+
if (value === undefined || value === null || value === "") missing.push("order." + field);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
const value = terms[name];
|
|
141
|
+
if (value === undefined || value === null || value === "") missing.push(name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return missing;
|
|
145
|
+
}
|