@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
package/src/quotes.mjs
CHANGED
|
@@ -21,10 +21,13 @@ import * as engine from "./engine.mjs";
|
|
|
21
21
|
import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
|
|
22
22
|
import { claimGasGiftWei } from "../engine/evm/claimGas.js";
|
|
23
23
|
import { parseXmr } from "../engine/core/xmrAmount.js";
|
|
24
|
+
import { takerHalves, takerHalvesV2, recoverySaltOf } from "./recovery-key.mjs";
|
|
25
|
+
import { acceptCounterparty, buildOwnSideFromHalves, jointAddress } from "./order.mjs";
|
|
26
|
+
import { requestFirmQuote } from "./relayFirmClient.mjs";
|
|
24
27
|
// THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
|
|
25
28
|
// remembered: a single wrong hex digit would call another function and return a zero address - and a zero
|
|
26
29
|
// address is exactly the answer "nobody owns this key", which the caller would take as the truth.
|
|
27
|
-
import { keccak256 } from "./primitives.mjs";
|
|
30
|
+
import { keccak256, secp256k1 } from "./primitives.mjs";
|
|
28
31
|
|
|
29
32
|
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
30
33
|
const bytesToHexQ = (bytes) => { let out = "0x"; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; };
|
|
@@ -42,6 +45,8 @@ const hexToBytesQ = (hex) => {
|
|
|
42
45
|
return out;
|
|
43
46
|
};
|
|
44
47
|
const ZERO_ADDRESS = "0x" + "00".repeat(20);
|
|
48
|
+
const sameText = (a, b) => String(a === undefined || a === null ? "" : a).toLowerCase()
|
|
49
|
+
=== String(b === undefined || b === null ? "" : b).toLowerCase();
|
|
45
50
|
const addressFromWord = (raw) => {
|
|
46
51
|
const h = String(raw == null ? "" : raw).replace(/^0x/, "");
|
|
47
52
|
return h.length >= 40 ? "0x" + h.slice(-40).toLowerCase() : null;
|
|
@@ -813,5 +818,244 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
813
818
|
}
|
|
814
819
|
return quote;
|
|
815
820
|
},
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* A firm quote through the relay, starting from the one recovery key.
|
|
824
|
+
*
|
|
825
|
+
* The caller gives the signed features, the order terms and the recovery key; this method derives the
|
|
826
|
+
* client half and both view points, sends one relay request and verifies the relay answer plus the
|
|
827
|
+
* on-chain registry/factory binding. It returns the signed `orderQuote` and a `quote` carrying the maker's
|
|
828
|
+
* view half, so `swaps.start` does not have to map relay fields by hand.
|
|
829
|
+
*/
|
|
830
|
+
async firmRelay(request) {
|
|
831
|
+
if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
|
|
832
|
+
const features = request.features;
|
|
833
|
+
const order = request.order && typeof request.order === "object" ? { ...request.order } : null;
|
|
834
|
+
if (!features || typeof features !== "object") fail("bad-input", { field: "features" });
|
|
835
|
+
if (!order) fail("bad-input", { field: "order" });
|
|
836
|
+
const side = String(request.side || "");
|
|
837
|
+
if (side !== "ask" && side !== "bid") fail("bad-input", { field: "side" });
|
|
838
|
+
const maker = String(features.provider || "").toLowerCase();
|
|
839
|
+
const registry = String(features.registry || "").toLowerCase();
|
|
840
|
+
if (!/^0x[0-9a-f]{40}$/.test(maker) || maker === ZERO_ADDRESS) fail("bad-input", { field: "features.provider" });
|
|
841
|
+
if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) fail("bad-input", { field: "features.registry" });
|
|
842
|
+
if (denylist && typeof order.locker === "string" && denylist.has(order.locker.toLowerCase())) {
|
|
843
|
+
fail("address-denied", { step: "order-quote" });
|
|
844
|
+
}
|
|
845
|
+
// THE PROVIDER'S ROLE IS PART OF THE SIDE. A caller that names another claimer/locker is not asking
|
|
846
|
+
// for this side; a caller that omits it gets the only value the wire allows.
|
|
847
|
+
if (side === "ask") {
|
|
848
|
+
if (order.claimer && !sameText(order.claimer, maker)) fail("bad-input", { field: "order.claimer" });
|
|
849
|
+
order.claimer = maker;
|
|
850
|
+
} else {
|
|
851
|
+
if (order.locker && !sameText(order.locker, maker)) fail("bad-input", { field: "order.locker" });
|
|
852
|
+
order.locker = maker;
|
|
853
|
+
}
|
|
854
|
+
const chainId = Number(order.chainId);
|
|
855
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0) fail("bad-input", { field: "order.chainId" });
|
|
856
|
+
const windows = Array.isArray(features.firmWindows)
|
|
857
|
+
? features.firmWindows.filter((entry) => entry && entry.side === side)
|
|
858
|
+
: [];
|
|
859
|
+
const markets = [...new Set(windows.map((entry) => entry.market).filter((value) => typeof value === "string" && value))];
|
|
860
|
+
const market = request.market !== undefined ? String(request.market) : (markets.length === 1 ? markets[0] : null);
|
|
861
|
+
if (!market) fail("bad-input", { field: "market" });
|
|
862
|
+
const window = windows.find((entry) => entry.market === market) || null;
|
|
863
|
+
if (!window) fail("quote-refused", { step: "features", why: "window" });
|
|
864
|
+
const xmrNetwork = request.xmrNetwork !== undefined ? String(request.xmrNetwork) : window.xmrNetwork;
|
|
865
|
+
if (!xmrNetwork) fail("bad-input", { field: "xmrNetwork" });
|
|
866
|
+
if (request.xmrAmount === undefined || request.xmrAmount === null || request.xmrAmount === "") {
|
|
867
|
+
fail("bad-input", { field: "xmrAmount" });
|
|
868
|
+
}
|
|
869
|
+
const claimTip = request.claimTip === undefined || request.claimTip === null ? "0" : String(request.claimTip);
|
|
870
|
+
const mnemonic = typeof request.recoveryKey === "string"
|
|
871
|
+
? request.recoveryKey
|
|
872
|
+
: request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
|
|
873
|
+
if (!mnemonic) fail("bad-input", { field: "recoveryKey" });
|
|
874
|
+
const saltRequested = request.recoverySalt !== undefined && request.recoverySalt !== null;
|
|
875
|
+
const indexRequested = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
|
|
876
|
+
if (saltRequested && indexRequested) fail("bad-input", { field: "recoverySalt", why: "with-recovery-index" });
|
|
877
|
+
let derived = null;
|
|
878
|
+
let derivation = null;
|
|
879
|
+
if (indexRequested) {
|
|
880
|
+
const index = Number(request.recoveryIndex);
|
|
881
|
+
if (!Number.isSafeInteger(index) || index < 0) fail("bad-input", { field: "recoveryIndex" });
|
|
882
|
+
derived = takerHalves({ mnemonic, chainId, index });
|
|
883
|
+
derivation = { kind: "index-v1", index };
|
|
884
|
+
} else {
|
|
885
|
+
if (!order.factory || !/^0x[0-9a-f]{40}$/i.test(String(order.factory))) fail("bad-input", { field: "order.factory" });
|
|
886
|
+
let salt = null;
|
|
887
|
+
try { salt = recoverySaltOf(saltRequested ? request.recoverySalt : order.salt); }
|
|
888
|
+
catch { fail("bad-input", { field: "recoverySalt" }); }
|
|
889
|
+
if (saltRequested) {
|
|
890
|
+
let orderSalt = null;
|
|
891
|
+
try { orderSalt = recoverySaltOf(order.salt); } catch { fail("bad-input", { field: "recoverySalt", why: "order-salt" }); }
|
|
892
|
+
if (salt !== orderSalt) fail("bad-input", { field: "recoverySalt", why: "order-salt" });
|
|
893
|
+
}
|
|
894
|
+
derived = takerHalvesV2({ mnemonic, chainId, factory: order.factory, salt });
|
|
895
|
+
derivation = { kind: "salt-v2", salt };
|
|
896
|
+
}
|
|
897
|
+
const own = buildOwnSideFromHalves(order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf });
|
|
898
|
+
const xa = secp256k1.Point.fromHex(own.side.proof.XA);
|
|
899
|
+
const clientHalf = {
|
|
900
|
+
commitHalf: bytesToHexQ(keccak256(xa.toBytes(false).slice(1))),
|
|
901
|
+
edPoint: own.side.pub.ed,
|
|
902
|
+
proof: own.side.proof,
|
|
903
|
+
};
|
|
904
|
+
const result = await requestFirmQuote({
|
|
905
|
+
relayUrl: request.relayUrl,
|
|
906
|
+
partner: request.partner,
|
|
907
|
+
fetchImpl: request.fetchImpl,
|
|
908
|
+
timeoutMs: request.timeoutMs,
|
|
909
|
+
features,
|
|
910
|
+
terms: {
|
|
911
|
+
market,
|
|
912
|
+
side,
|
|
913
|
+
maker,
|
|
914
|
+
registry,
|
|
915
|
+
xmrNetwork,
|
|
916
|
+
order,
|
|
917
|
+
xmrAmount: String(request.xmrAmount),
|
|
918
|
+
claimTip,
|
|
919
|
+
},
|
|
920
|
+
clientHalf,
|
|
921
|
+
edViewPointClient: own.side.viewPub,
|
|
922
|
+
edViewPointNode: derived.makerViewPoint,
|
|
923
|
+
nodeViewScalar: derived.makerViewHalf,
|
|
924
|
+
clientViewScalar: side === "bid" ? derived.viewHalf : null,
|
|
925
|
+
side,
|
|
926
|
+
});
|
|
927
|
+
const quote = result.quote;
|
|
928
|
+
// THE NODE HALF IS NOT A DECORATION. The answer's proof must stand in THIS order's context and the proven
|
|
929
|
+
// point must be the one the quote commits to. For ask that is the claimer's commitment; for bid the node
|
|
930
|
+
// half is the locker's and carries its own commitment. This is the same engine check the swap path uses.
|
|
931
|
+
const nodeHalf = result.nodeHalf;
|
|
932
|
+
acceptCounterparty({
|
|
933
|
+
context: own.context,
|
|
934
|
+
side: own.side,
|
|
935
|
+
counterparty: side === "ask"
|
|
936
|
+
? {
|
|
937
|
+
proof: nodeHalf.proof,
|
|
938
|
+
edPub: quote.edPointClaimer,
|
|
939
|
+
commitHalfClaimer: quote.commitHalfClaimer,
|
|
940
|
+
viewPoint: result.edViewPointClaimer,
|
|
941
|
+
viewHalf: derived.makerViewHalf,
|
|
942
|
+
}
|
|
943
|
+
: {
|
|
944
|
+
proof: nodeHalf.proof,
|
|
945
|
+
edPub: nodeHalf.edPoint,
|
|
946
|
+
commitHalfClaimer: nodeHalf.commitHalf,
|
|
947
|
+
viewPoint: result.edViewPointLocker,
|
|
948
|
+
viewHalf: derived.makerViewHalf,
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
// THE ONE-TIME XMR ADDRESS IS DERIVED, NOT TAKEN FROM THE RELAY. Both sides must assemble it from the same
|
|
952
|
+
// points: our spend/view halves from the recovery key, the node's spend point and view point from the answer.
|
|
953
|
+
// For ask we are the locker and the node is the claimer; for bid the roles are swapped.
|
|
954
|
+
const nodeSpendPoint = side === "ask" ? quote.edPointClaimer : nodeHalf.edPoint;
|
|
955
|
+
const nodeViewPoint = side === "ask" ? result.edViewPointClaimer : result.edViewPointLocker;
|
|
956
|
+
const oneTimeAddress = jointAddress({
|
|
957
|
+
ownSpendHalf: derived.spendHalf,
|
|
958
|
+
ownViewHalf: derived.viewHalf,
|
|
959
|
+
otherSpendPoint: nodeSpendPoint,
|
|
960
|
+
otherViewPoint: nodeViewPoint,
|
|
961
|
+
network: xmrNetwork,
|
|
962
|
+
});
|
|
963
|
+
// THE ESCROW ADDRESS IS A READ, NOT A GUESS. predict() is a free call and uses the same signed quote the
|
|
964
|
+
// factory will receive; the locker fields are the side that deposits ETH. A read failure is a named refusal,
|
|
965
|
+
// because a client that cannot name the escrow cannot watch it.
|
|
966
|
+
const lockerHalf = side === "ask"
|
|
967
|
+
? { commitHalfLocker: clientHalf.commitHalf, edPointLocker: own.side.pub.ed, edViewPointLocker: own.side.viewPub }
|
|
968
|
+
: { commitHalfLocker: nodeHalf.commitHalf, edPointLocker: nodeHalf.edPoint, edViewPointLocker: result.edViewPointLocker };
|
|
969
|
+
let escrow = null;
|
|
970
|
+
try {
|
|
971
|
+
const predicted = await evmCall({
|
|
972
|
+
to: quote.factory,
|
|
973
|
+
data: engine.evm.encodePredict({ ...result.orderQuote, ...lockerHalf, signature: result.orderQuote.signature }),
|
|
974
|
+
});
|
|
975
|
+
escrow = engine.evm.decodeAddress(predicted);
|
|
976
|
+
} catch {
|
|
977
|
+
fail("quote-escrow-unchecked", { step: "factory", why: "predict-failed", factory: quote.factory });
|
|
978
|
+
}
|
|
979
|
+
if (providerAllowed && providerAllowed(maker) !== true) {
|
|
980
|
+
fail("quote-provider-not-allowed", { step: "policy", provider: maker });
|
|
981
|
+
}
|
|
982
|
+
if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
|
|
983
|
+
let mappedProvider = null;
|
|
984
|
+
try {
|
|
985
|
+
mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(quote.quoteKey) }));
|
|
986
|
+
} catch { fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry }); }
|
|
987
|
+
if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) fail("quote-key-unknown", { step: "registry", registry, quoteKey: quote.quoteKey });
|
|
988
|
+
if (!sameText(mappedProvider, quote.provider)) {
|
|
989
|
+
fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: quote.provider });
|
|
990
|
+
}
|
|
991
|
+
let recordedFactory = null;
|
|
992
|
+
try {
|
|
993
|
+
recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(quote.provider) }));
|
|
994
|
+
} catch { fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry }); }
|
|
995
|
+
if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
|
|
996
|
+
fail("quote-factory-unknown", { step: "factory", registry, provider: quote.provider });
|
|
997
|
+
}
|
|
998
|
+
if (!sameText(recordedFactory, quote.factory)) {
|
|
999
|
+
fail("quote-factory-mismatch", { step: "factory", got: quote.factory, want: recordedFactory });
|
|
1000
|
+
}
|
|
1001
|
+
if (factoryPolicy) {
|
|
1002
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
1003
|
+
let codeHex = null;
|
|
1004
|
+
try { codeHex = await evmCode(quote.factory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: quote.factory }); }
|
|
1005
|
+
const codeHash = bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0)));
|
|
1006
|
+
if (!factoryPolicy.includes(codeHash)) fail("quote-factory-code-unknown", { step: "factory", factory: quote.factory, codeHash });
|
|
1007
|
+
}
|
|
1008
|
+
if (implementationPolicy) {
|
|
1009
|
+
if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
|
|
1010
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
1011
|
+
const verdict = await engine.evm.verifyFactoryImplementationOnChain({
|
|
1012
|
+
factory: quote.factory,
|
|
1013
|
+
read: evmCall,
|
|
1014
|
+
code: (address) => evmCode(address),
|
|
1015
|
+
policy: implementationPolicy,
|
|
1016
|
+
hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
|
|
1017
|
+
});
|
|
1018
|
+
if (!verdict.ok) {
|
|
1019
|
+
const unknown = verdict.why === "implementation-code-unknown" ||
|
|
1020
|
+
verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
|
|
1021
|
+
fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
|
|
1022
|
+
{ step: "factory", why: verdict.why, factory: quote.factory, implementation: verdict.implementation || null });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const tipCheck = (() => {
|
|
1026
|
+
try { return claimTipVerdict({ claimTipWei: quote.claimTip }); }
|
|
1027
|
+
catch { return { ok: true, tipWei: "0" }; }
|
|
1028
|
+
})();
|
|
1029
|
+
if (!tipCheck.ok && tipCheck.code === "quote-tip-unreadable") {
|
|
1030
|
+
fail("quote-field-unbound", { step: "claimTip", field: "claimTip" });
|
|
1031
|
+
}
|
|
1032
|
+
if (tipCheck.tipWei !== "0") {
|
|
1033
|
+
if (typeof evmBlock !== "function") fail("quote-tip-unchecked", { step: "claimTip", why: "no-block-read" });
|
|
1034
|
+
let block = null;
|
|
1035
|
+
try { block = await evmBlock("latest"); } catch { fail("quote-tip-unchecked", { step: "claimTip", why: "block-read-failed" }); }
|
|
1036
|
+
const baseFeeWei = block && (block.baseFeePerGas !== undefined ? block.baseFeePerGas : block.baseFeeWei);
|
|
1037
|
+
const verdict = claimTipVerdict({ claimTipWei: quote.claimTip, baseFeeWei });
|
|
1038
|
+
if (!verdict.ok) fail(verdict.code, { step: "claimTip", ...verdict });
|
|
1039
|
+
}
|
|
1040
|
+
return {
|
|
1041
|
+
...result,
|
|
1042
|
+
quote: { ...quote, viewHalfClaimer: derived.makerViewHalf },
|
|
1043
|
+
order,
|
|
1044
|
+
// THE SHAPE `swaps.start` TAKES: the proof and the claimer commitment travel with the view half, so a
|
|
1045
|
+
// caller can pass this object as `counterparty` without rebuilding the relay fields.
|
|
1046
|
+
counterparty: {
|
|
1047
|
+
proof: nodeHalf.proof,
|
|
1048
|
+
edPointClaimer: side === "ask" ? quote.edPointClaimer : nodeHalf.edPoint,
|
|
1049
|
+
commitHalfClaimer: side === "ask" ? quote.commitHalfClaimer : nodeHalf.commitHalf,
|
|
1050
|
+
viewHalfClaimer: derived.makerViewHalf,
|
|
1051
|
+
viewPoint: side === "ask" ? result.edViewPointClaimer : result.edViewPointLocker,
|
|
1052
|
+
},
|
|
1053
|
+
viewHalfClaimer: derived.makerViewHalf,
|
|
1054
|
+
viewHalfPoint: derived.makerViewPoint,
|
|
1055
|
+
jointAddress: oneTimeAddress.address,
|
|
1056
|
+
escrow,
|
|
1057
|
+
recovery: derivation,
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
816
1060
|
};
|
|
817
1061
|
}
|
package/src/record.mjs
CHANGED
|
@@ -54,6 +54,16 @@ export function createRecord(input, { order = null, side: knownSide = null } = {
|
|
|
54
54
|
// both the slot check against the chain and the address check read them.
|
|
55
55
|
half: halfHex(side.half), viewHalf: halfHex(side.viewHalf),
|
|
56
56
|
edPointLocker: norm(side.pub.ed), edViewPointLocker: norm(side.viewPub) };
|
|
57
|
+
// THE XMR AMOUNT THE PROVIDER SIGNED, IN ATOMIC UNITS. The record's `xmrAmount` is a display number rounded
|
|
58
|
+
// from the quote; the signed order carries the exact integer. The arrival check compares against the signed
|
|
59
|
+
// figure, so it must survive the record and the recovery file unchanged (never re-derived from the display
|
|
60
|
+
// number). A malformed value is a refusal before anything is saved.
|
|
61
|
+
if (input.expectedAtomic !== undefined && input.expectedAtomic !== null) {
|
|
62
|
+
let atomic;
|
|
63
|
+
try { atomic = BigInt(String(input.expectedAtomic)); } catch { throw new SdkError("bad-input", { field: "expectedAtomic" }); }
|
|
64
|
+
if (atomic <= 0n) throw new SdkError("bad-input", { field: "expectedAtomic" });
|
|
65
|
+
escrow.expectedAtomic = atomic.toString();
|
|
66
|
+
}
|
|
57
67
|
// THE SIDE'S ENCRYPTION KEY - only the public one: the private one stays with the side and never enters the record.
|
|
58
68
|
if (side.enc && side.enc.pub) escrow.encPub = norm(side.enc.pub);
|
|
59
69
|
const record = { ...swap, escrow };
|
package/src/recovery-key.mjs
CHANGED
|
@@ -30,6 +30,13 @@ export const RECOVERY_KEY_WORDS = 12;
|
|
|
30
30
|
// The allocator's ceiling. Discovery must use the same range: a search that stops earlier can miss an order
|
|
31
31
|
// whose number was skipped by an abandoned start or by a swap on another chain.
|
|
32
32
|
export const RECOVERY_INDEX_MAX = 100000;
|
|
33
|
+
// A DISCOVERY SCAN IS BOUNDED BY DEFAULT. The allocator can reach 100,000, but deriving a secp256k1 point for
|
|
34
|
+
// every number on a phone is not a page-open cost. One thousand indices keeps the common case inside a short
|
|
35
|
+
// call and, more importantly, the result NAMES that it stopped: the caller can continue from `nextIndex`.
|
|
36
|
+
export const RECOVERY_SCAN_DEFAULT = 1000;
|
|
37
|
+
// The scan yields to the event loop between chunks. A page must be able to paint and answer a cancel click even
|
|
38
|
+
// when a caller explicitly asks for a large range; a synchronous loop of 100,000 derivations froze the page.
|
|
39
|
+
export const RECOVERY_SCAN_YIELD_EVERY = 64;
|
|
33
40
|
export const RECOVERY_KEY_STORE = "ninsei.recovery.key.v1";
|
|
34
41
|
export const RECOVERY_KEY_FILE_MAGIC = "NINSEI RECOVERY KEY";
|
|
35
42
|
export const RECOVERY_KEY_DOMAIN = "ninsei-taker-v1";
|
|
@@ -650,7 +657,9 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
650
657
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
651
658
|
*/
|
|
652
659
|
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
653
|
-
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5,
|
|
660
|
+
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5,
|
|
661
|
+
fromIndex = 0, maxIndex = RECOVERY_SCAN_DEFAULT,
|
|
662
|
+
signal = null, onProgress = null,
|
|
654
663
|
evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
|
|
655
664
|
xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
|
|
656
665
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
@@ -659,7 +668,10 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
659
668
|
// numbers are ordinary when a start was abandoned or the key was used on another chain, and stopping there
|
|
660
669
|
// hides a funded order whose log is already in `logs`.
|
|
661
670
|
void gap;
|
|
662
|
-
const
|
|
671
|
+
const startIndex = numberField(fromIndex, "fromIndex");
|
|
672
|
+
if (startIndex < 0 || startIndex >= RECOVERY_INDEX_MAX) throw bad("fromIndex");
|
|
673
|
+
const asked = numberField(maxIndex, "maxIndex");
|
|
674
|
+
const max = Math.min(asked, RECOVERY_INDEX_MAX - startIndex);
|
|
663
675
|
// THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
|
|
664
676
|
// factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
|
|
665
677
|
// last resort for a caller that knows the factory existed at genesis.
|
|
@@ -733,19 +745,45 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
733
745
|
byCommit.get(claimer).push({ order, side: "claimer" });
|
|
734
746
|
}
|
|
735
747
|
}
|
|
736
|
-
const
|
|
748
|
+
const v1Keys = orders.filter((order) => !v2Matched.has(orderKey(order))).map(orderKey);
|
|
749
|
+
const v1Needed = v1Keys.length > 0;
|
|
750
|
+
const remaining = new Set(v1Keys);
|
|
737
751
|
let scanned = 0;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
752
|
+
let stoppedBy = null;
|
|
753
|
+
if (onProgress !== null && typeof onProgress !== "function") throw bad("onProgress");
|
|
754
|
+
if (v1Needed && signal && signal.aborted === true) stoppedBy = "aborted";
|
|
755
|
+
if (v1Needed && stoppedBy === null) {
|
|
756
|
+
for (let offset = 0; offset < max; offset += 1) {
|
|
757
|
+
const index = startIndex + offset;
|
|
758
|
+
scanned = offset + 1;
|
|
741
759
|
const commitment = spendCommitmentFromSeed(seed, chain, index);
|
|
742
760
|
const matches = byCommit.get(commitment) || [];
|
|
743
761
|
for (const { order, side } of matches) {
|
|
744
762
|
const key = orderKey(order);
|
|
745
763
|
if (foundKeys.has(key)) continue;
|
|
746
764
|
foundKeys.add(key);
|
|
765
|
+
remaining.delete(key);
|
|
747
766
|
found.push({ ...order, index, derivation: "index-v1", side });
|
|
748
767
|
}
|
|
768
|
+
// EVERY MATCHED ORDER ENDS ITS OWN SEARCH. A key with only its own orders in the logs does not pay for
|
|
769
|
+
// the rest of the bound once the last one is found. A shared factory keeps the unrelated logs, so the
|
|
770
|
+
// scan continues to the requested bound and reports that it stopped there.
|
|
771
|
+
const final = remaining.size === 0 || offset === max - 1;
|
|
772
|
+
if (final || scanned % RECOVERY_SCAN_YIELD_EVERY === 0) {
|
|
773
|
+
// Progress is advisory: a page callback that throws must not turn a completed scan into a failed one.
|
|
774
|
+
if (typeof onProgress === "function") {
|
|
775
|
+
try { onProgress({ scanned, index }); } catch { /* progress is not the result */ }
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// A CALLER CAN CANCEL. The check is after the progress callback, so a worker that aborts from its
|
|
779
|
+
// progress handler stops at this chunk instead of finishing the requested range.
|
|
780
|
+
if (signal && signal.aborted === true) { stoppedBy = "aborted"; break; }
|
|
781
|
+
if (remaining.size === 0) break;
|
|
782
|
+
// A LARGE CALLER-SUPPLIED BOUND MUST NOT OWN THE MAIN THREAD. The scan yields between chunks so the page
|
|
783
|
+
// can paint, and a later call can continue from `nextIndex`.
|
|
784
|
+
if (scanned % RECOVERY_SCAN_YIELD_EVERY === 0) {
|
|
785
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
786
|
+
}
|
|
749
787
|
}
|
|
750
788
|
}
|
|
751
789
|
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
@@ -796,15 +834,17 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
796
834
|
order.xmrAmount = read.value;
|
|
797
835
|
order.xmrAmountReason = read.reason;
|
|
798
836
|
}
|
|
799
|
-
const
|
|
837
|
+
const searchedUpTo = scanned ? startIndex + scanned - 1 : null;
|
|
838
|
+
const complete = !v1Needed || remaining.size === 0 || startIndex + scanned >= RECOVERY_INDEX_MAX;
|
|
839
|
+
const nextIndex = complete ? null : startIndex + scanned;
|
|
800
840
|
return {
|
|
801
841
|
swaps: found,
|
|
802
842
|
scanned,
|
|
803
843
|
// A CALLER MUST NOT READ "NO SWAPS" AS "THE WHOLE RANGE WAS SEARCHED". The allocator ceiling is the only
|
|
804
844
|
// complete range; a smaller caller-supplied bound says where the search stopped. A result made only of v2
|
|
805
845
|
// orders has no range to search and is complete by construction.
|
|
806
|
-
searchedUpTo
|
|
846
|
+
searchedUpTo,
|
|
807
847
|
complete,
|
|
808
|
-
...(complete ? {} : { incompleteReason: "max-index" }),
|
|
848
|
+
...(complete ? {} : { incompleteReason: stoppedBy || "max-index", nextIndex }),
|
|
809
849
|
};
|
|
810
850
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// THE PUBLIC FEATURES A RELAY MAKER SIGNS.
|
|
2
|
+
//
|
|
3
|
+
// A relay price answer carries a `node_features` string signed by the maker's quote key. The client must check
|
|
4
|
+
// that signature before it uses the `hpkeKey`, the registry or the firm windows: an unsigned or substituted key
|
|
5
|
+
// would receive view material for another maker. This module owns the EIP-712 type, the relay domain and the
|
|
6
|
+
// verification; the request builder takes only the verified result.
|
|
7
|
+
//
|
|
8
|
+
// The public object has no contact fields. The relay forwards the exact string it received; this module hashes
|
|
9
|
+
// the decoded values, not a reconstructed subset.
|
|
10
|
+
import { SdkError } from "./errors.mjs";
|
|
11
|
+
import { keccak256, secp256k1 } from "./primitives.mjs";
|
|
12
|
+
|
|
13
|
+
export const RELAY_FEATURES_TYPE_NAME = "NodeFeatures";
|
|
14
|
+
export const RELAY_DOMAIN_NAME = "Ninsei Relay";
|
|
15
|
+
export const RELAY_DOMAIN_VERSION = "1";
|
|
16
|
+
|
|
17
|
+
const DOMAIN_TYPE = "EIP712Domain(string name,string version)";
|
|
18
|
+
export const FIRM_WINDOW_TYPE =
|
|
19
|
+
"FirmWindow(string market,string side,string xmrNetwork,uint32 readyMinSeconds,uint32 readyMaxSeconds,uint32 settlementMinSeconds,uint32 settlementMaxSeconds,uint32 quoteTtlSeconds)";
|
|
20
|
+
export const NODE_FEATURES_TYPE =
|
|
21
|
+
"NodeFeatures(string relayId,address provider,address quoteKey,address registry,string[] capabilities,bytes32 hpkeKey,FirmWindow[] firmWindows,uint64 time,bytes32 nonce)" +
|
|
22
|
+
FIRM_WINDOW_TYPE;
|
|
23
|
+
|
|
24
|
+
export const FIRM_WINDOW_FIELDS = [
|
|
25
|
+
["market", "string"],
|
|
26
|
+
["side", "string"],
|
|
27
|
+
["xmrNetwork", "string"],
|
|
28
|
+
["readyMinSeconds", "uint32"],
|
|
29
|
+
["readyMaxSeconds", "uint32"],
|
|
30
|
+
["settlementMinSeconds", "uint32"],
|
|
31
|
+
["settlementMaxSeconds", "uint32"],
|
|
32
|
+
["quoteTtlSeconds", "uint32"],
|
|
33
|
+
];
|
|
34
|
+
export const NODE_FEATURES_FIELDS = [
|
|
35
|
+
["relayId", "string"],
|
|
36
|
+
["provider", "address"],
|
|
37
|
+
["quoteKey", "address"],
|
|
38
|
+
["registry", "address"],
|
|
39
|
+
["capabilities", "string[]"],
|
|
40
|
+
["hpkeKey", "bytes32"],
|
|
41
|
+
["firmWindows", "FirmWindow[]"],
|
|
42
|
+
["time", "uint64"],
|
|
43
|
+
["nonce", "bytes32"],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
47
|
+
const concat = (...arrays) => {
|
|
48
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
49
|
+
const out = new Uint8Array(total);
|
|
50
|
+
let at = 0;
|
|
51
|
+
for (const array of arrays) { out.set(array, at); at += array.length; }
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
54
|
+
const hexBytes = (value, field, width) => {
|
|
55
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
56
|
+
if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) {
|
|
57
|
+
throw new SdkError("bad-input", { field });
|
|
58
|
+
}
|
|
59
|
+
const out = new Uint8Array(width);
|
|
60
|
+
for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
const numberWord = (value, field, bits) => {
|
|
64
|
+
let n = null;
|
|
65
|
+
try { n = BigInt(String(value === undefined || value === null ? "" : value).trim()); } catch { n = null; }
|
|
66
|
+
if (n === null || n < 0n || n >= (1n << BigInt(bits))) throw new SdkError("bad-input", { field });
|
|
67
|
+
const out = new Uint8Array(32);
|
|
68
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
69
|
+
return out;
|
|
70
|
+
};
|
|
71
|
+
const addressWord = (value, field) => {
|
|
72
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
73
|
+
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(text)) throw new SdkError("bad-input", { field });
|
|
74
|
+
const out = new Uint8Array(32);
|
|
75
|
+
out.set(hexBytes(text, field, 20), 12);
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
const stringWord = (value) => keccak256(utf8(value === undefined || value === null ? "" : value));
|
|
79
|
+
const arrayWord = (values, field, hashOf) => {
|
|
80
|
+
if (!Array.isArray(values)) throw new SdkError("bad-input", { field });
|
|
81
|
+
const parts = values.map((value) => hashOf(value));
|
|
82
|
+
return keccak256(concat(...parts));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function firmWindowHash(window) {
|
|
86
|
+
if (!window || typeof window !== "object") throw new SdkError("bad-input", { field: "firmWindows" });
|
|
87
|
+
const parts = [keccak256(utf8(FIRM_WINDOW_TYPE))];
|
|
88
|
+
for (const [field, type] of FIRM_WINDOW_FIELDS) {
|
|
89
|
+
parts.push(type === "string" ? stringWord(window[field]) : numberWord(window[field], "firmWindows." + field, 32));
|
|
90
|
+
}
|
|
91
|
+
return keccak256(concat(...parts));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function nodeFeaturesStructHash(features) {
|
|
95
|
+
if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
|
|
96
|
+
const parts = [keccak256(utf8(NODE_FEATURES_TYPE))];
|
|
97
|
+
for (const [field, type] of NODE_FEATURES_FIELDS) {
|
|
98
|
+
if (type === "string") parts.push(stringWord(features[field]));
|
|
99
|
+
else if (type === "address") parts.push(addressWord(features[field], "features." + field));
|
|
100
|
+
else if (type === "bytes32") parts.push(hexBytes(features[field], "features." + field, 32));
|
|
101
|
+
else if (type === "uint64") parts.push(numberWord(features[field], "features." + field, 64));
|
|
102
|
+
else if (type === "string[]") parts.push(arrayWord(features[field], "features." + field, (value) => stringWord(value)));
|
|
103
|
+
else if (type === "FirmWindow[]") parts.push(arrayWord(features[field], "features." + field, firmWindowHash));
|
|
104
|
+
else throw new SdkError("bad-input", { field: "features." + field });
|
|
105
|
+
}
|
|
106
|
+
return keccak256(concat(...parts));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function nodeFeaturesDomainSeparator() {
|
|
110
|
+
return keccak256(concat(
|
|
111
|
+
keccak256(utf8(DOMAIN_TYPE)),
|
|
112
|
+
keccak256(utf8(RELAY_DOMAIN_NAME)),
|
|
113
|
+
keccak256(utf8(RELAY_DOMAIN_VERSION)),
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function nodeFeaturesDigest(features) {
|
|
118
|
+
return keccak256(concat(Uint8Array.from([0x19, 0x01]), nodeFeaturesDomainSeparator(), nodeFeaturesStructHash(features)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const bytesToHex = (bytes) => {
|
|
122
|
+
let out = "0x";
|
|
123
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function recoverAddress(digest, signature) {
|
|
128
|
+
const sig = String(signature || "").replace(/^0x/, "");
|
|
129
|
+
if (!/^[0-9a-fA-F]{130}$/.test(sig)) return null;
|
|
130
|
+
let v = parseInt(sig.slice(128, 130), 16);
|
|
131
|
+
if (v >= 27) v -= 27;
|
|
132
|
+
if (v !== 0 && v !== 1) return null;
|
|
133
|
+
try {
|
|
134
|
+
const publicKey = secp256k1.Signature.fromBytes(concat(
|
|
135
|
+
hexBytes(sig.slice(0, 64), "signature", 32),
|
|
136
|
+
hexBytes(sig.slice(64, 128), "signature", 32),
|
|
137
|
+
)).addRecoveryBit(v).recoverPublicKey(digest);
|
|
138
|
+
return bytesToHex(keccak256(publicKey.toBytes(false).slice(1)).slice(12));
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Verify the signed public features. `terms` optionally binds the features to one request: the provider, the
|
|
146
|
+
* registry, the market, the side and the Monero network must agree. A zero key, a short key, a missing or empty
|
|
147
|
+
* window list, or a signature from another key is a refusal.
|
|
148
|
+
*/
|
|
149
|
+
export function verifyNodeFeatures({ features, terms = null } = {}) {
|
|
150
|
+
if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
|
|
151
|
+
if (features.type !== "node_features" || Number(features.version) !== 1) {
|
|
152
|
+
throw new SdkError("quote-refused", { step: "features", why: "version" });
|
|
153
|
+
}
|
|
154
|
+
const digest = nodeFeaturesDigest(features);
|
|
155
|
+
const recovered = recoverAddress(digest, features.signature);
|
|
156
|
+
const quoteKey = String(features.quoteKey || "").toLowerCase();
|
|
157
|
+
if (recovered === null || recovered !== quoteKey) {
|
|
158
|
+
throw new SdkError("quote-refused", { step: "features", why: "signature" });
|
|
159
|
+
}
|
|
160
|
+
const hpkeKey = String(features.hpkeKey || "").replace(/^0x/, "").toLowerCase();
|
|
161
|
+
if (!Array.isArray(features.capabilities) || features.capabilities.length === 0
|
|
162
|
+
|| features.capabilities.length > 16
|
|
163
|
+
|| new Set(features.capabilities).size !== features.capabilities.length
|
|
164
|
+
|| features.capabilities.some((name) => typeof name !== "string" || name === ""
|
|
165
|
+
|| name.length > 32 || !/^[ -~]+$/.test(name))) {
|
|
166
|
+
throw new SdkError("quote-refused", { step: "features", why: "capabilities" });
|
|
167
|
+
}
|
|
168
|
+
const firm = features.capabilities.includes("firm/1");
|
|
169
|
+
if (firm && (!/^[0-9a-f]{64}$/.test(hpkeKey) || /^0+$/.test(hpkeKey))) {
|
|
170
|
+
throw new SdkError("quote-refused", { step: "features", why: "hpke-key" });
|
|
171
|
+
}
|
|
172
|
+
if (firm && (!Array.isArray(features.firmWindows) || features.firmWindows.length === 0)) {
|
|
173
|
+
throw new SdkError("quote-refused", { step: "features", why: "windows" });
|
|
174
|
+
}
|
|
175
|
+
if (!firm && hpkeKey !== "0".repeat(64)) {
|
|
176
|
+
throw new SdkError("quote-refused", { step: "features", why: "hpke-key" });
|
|
177
|
+
}
|
|
178
|
+
if (!firm && Array.isArray(features.firmWindows) && features.firmWindows.length !== 0) {
|
|
179
|
+
throw new SdkError("quote-refused", { step: "features", why: "windows" });
|
|
180
|
+
}
|
|
181
|
+
if (terms) {
|
|
182
|
+
if (String(features.provider || "").toLowerCase() !== String(terms.maker || "").toLowerCase()
|
|
183
|
+
|| String(features.registry || "").toLowerCase() !== String(terms.registry || "").toLowerCase()) {
|
|
184
|
+
throw new SdkError("quote-refused", { step: "features", why: "identity" });
|
|
185
|
+
}
|
|
186
|
+
const market = String(terms.market || "");
|
|
187
|
+
const side = String(terms.side || "");
|
|
188
|
+
const network = String(terms.xmrNetwork || "");
|
|
189
|
+
const window = features.firmWindows.find((entry) => entry
|
|
190
|
+
&& entry.market === market && entry.side === side && entry.xmrNetwork === network);
|
|
191
|
+
if (!window) throw new SdkError("quote-refused", { step: "features", why: "window" });
|
|
192
|
+
return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window };
|
|
193
|
+
}
|
|
194
|
+
return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Absent fields are named, not zero-filled.
|
|
198
|
+
export function nodeFeaturesMissingFields(features) {
|
|
199
|
+
if (!features || typeof features !== "object") return ["features"];
|
|
200
|
+
const missing = [];
|
|
201
|
+
for (const [field] of NODE_FEATURES_FIELDS) {
|
|
202
|
+
const value = features[field];
|
|
203
|
+
if (value === undefined || value === null || value === "") missing.push(field);
|
|
204
|
+
}
|
|
205
|
+
return missing;
|
|
206
|
+
}
|