@ninsei-labs/sdk 0.39.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 +866 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/engine/atomic/dleq2.js +330 -0
- package/engine/atomic/halfenc.js +205 -0
- package/engine/atomic/halves.js +119 -0
- package/engine/atomic/order-client.js +57 -0
- package/engine/atomic/order.js +132 -0
- package/engine/atomic/orderContext.js +70 -0
- package/engine/atomic/orderHalfCommit.js +29 -0
- package/engine/core/chainSource.js +306 -0
- package/engine/core/clock.js +40 -0
- package/engine/core/config.js +885 -0
- package/engine/core/finality.js +93 -0
- package/engine/core/format.js +168 -0
- package/engine/core/preSignGuards.js +495 -0
- package/engine/core/rfqSource.js +193 -0
- package/engine/core/sellWindow.js +43 -0
- package/engine/core/store.js +100 -0
- package/engine/core/swap.js +1153 -0
- package/engine/evm/amounts.js +131 -0
- package/engine/evm/auth.js +156 -0
- package/engine/evm/claimGas.js +87 -0
- package/engine/evm/depositor.js +127 -0
- package/engine/evm/dex.js +836 -0
- package/engine/evm/escrow.js +208 -0
- package/engine/evm/factory.js +149 -0
- package/engine/evm/fees.js +90 -0
- package/engine/evm/funding.js +309 -0
- package/engine/evm/gasReserve.js +88 -0
- package/engine/evm/index.js +123 -0
- package/engine/evm/injected.js +175 -0
- package/engine/evm/permit.js +125 -0
- package/engine/evm/prices.js +179 -0
- package/engine/evm/session.js +777 -0
- package/engine/mock/chain.js +98 -0
- package/engine/monero/address.js +265 -0
- package/engine/monero/halvesSweep.js +68 -0
- package/engine/monero/node.js +110 -0
- package/engine/monero/scan.js +325 -0
- package/engine/monero/swapKeys.js +69 -0
- package/engine/monero/unlock.js +46 -0
- package/engine/monero/wallet.js +168 -0
- package/engine/recovery/recoveryFile.js +363 -0
- package/engine/recovery/restore.js +176 -0
- package/engine/sdk/bridge.js +609 -0
- package/engine/ui/dom.js +90 -0
- package/engine/ui/walletModal.js +132 -0
- package/index.d.ts +1417 -0
- package/package.json +74 -0
- package/src/actions.mjs +72 -0
- package/src/adapters/evm-wallet.mjs +126 -0
- package/src/adapters/index.mjs +21 -0
- package/src/adapters/local-storage.mjs +32 -0
- package/src/adapters/monero-wallet.mjs +185 -0
- package/src/adapters/monero.mjs +51 -0
- package/src/adapters/node-test-evm-wallet.mjs +345 -0
- package/src/codes.mjs +188 -0
- package/src/config.mjs +277 -0
- package/src/engine.mjs +53 -0
- package/src/errors.mjs +24 -0
- package/src/http.mjs +72 -0
- package/src/index.mjs +256 -0
- package/src/legs/evm.mjs +118 -0
- package/src/legs/index.mjs +50 -0
- package/src/levelsWalkSpec.mjs +183 -0
- package/src/lock.mjs +140 -0
- package/src/order.mjs +212 -0
- package/src/preflight.mjs +137 -0
- package/src/primitives.mjs +47 -0
- package/src/quoteEip712.mjs +35 -0
- package/src/quoteEip712Spec.mjs +214 -0
- package/src/quotes.mjs +465 -0
- package/src/record.mjs +65 -0
- package/src/recovery.mjs +40 -0
- package/src/sim.mjs +37 -0
- package/src/swap-flow.mjs +492 -0
- package/src/swaps.mjs +935 -0
- package/src/sweep.mjs +222 -0
- package/src/wallet.mjs +164 -0
- package/src/xmr-arrival.mjs +289 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// THE ATOMIC ORDER FLOW: ONE SEQUENCE, ASSEMBLED IN ONE PLACE.
|
|
2
|
+
//
|
|
3
|
+
// WHY A SEPARATE MODULE. The rule "create the order -> lock the funds" used to be smeared across the interface:
|
|
4
|
+
// the UI called a simulation while the funding module waited for ready-made sealedCommitment and sealedHalf. Now
|
|
5
|
+
// the order is fixed here, and there is no step that can be skipped by accident:
|
|
6
|
+
//
|
|
7
|
+
// 1) build your own side (the half + the proof) - in the WORKER, so the page does not freeze;
|
|
8
|
+
// 2) assemble the counterparty's side (in the demo - a local stand-in, marked standIn);
|
|
9
|
+
// 3) CHECK the counterparty against the single mandatory condition - the DLEQ proof in the context of THIS
|
|
10
|
+
// order (there is no envelope in the scheme any more, doc 27) - before any locking: after it, that would no
|
|
11
|
+
// longer help. The item "the envelope that confirms the half" left together with the envelope itself;
|
|
12
|
+
// 4) obtain commitments on BOTH halves: our own is computed by the worker over our half (only the hash goes
|
|
13
|
+
// out), the counterparty's commitment arrives in the quote. There is no longer any need to encrypt the half
|
|
14
|
+
// and hand it to the counterparty: it is revealed on-chain at settlement (claim/refund);
|
|
15
|
+
// 5) and only now put the money in: with ONE createOrderAndFund transaction that creates the escrow together
|
|
16
|
+
// with the money (the "lock" step below). The former name lock(sealedHalf) lied twice here: the contract has
|
|
17
|
+
// no such function - the payable constructor moves the money - and there is no envelope in the order. Nor
|
|
18
|
+
// does the escrow have a separate funding function: an order is either born with money or not born at all.
|
|
19
|
+
//
|
|
20
|
+
// THE CRYPTOGRAPHY LIVES UNDER www/js/atomic/* (mirrored as ../engine/atomic/*) AND IS CALLED ONLY THROUGH THE
|
|
21
|
+
// WORKER. There is no crypto here - here is the order.
|
|
22
|
+
//
|
|
23
|
+
// WHY IT IS IN THE PACKAGE NOW. The page used to import this module straight from www/js/core/swap-flow.js; the
|
|
24
|
+
// core (www/js, mirrored under ../engine/) was the second home of the deal flow. That is exactly the debt
|
|
25
|
+
// issue #32 pays off: the deal engine is business logic, so it belongs to the package (@ninsei-labs/sdk), and
|
|
26
|
+
// the page reaches it through ONE seam (www/js/sdk/bridge.js) by module name, not by pulling a file out of
|
|
27
|
+
// www/js. The engine modules it needs are still read through the same verified mirror (./engine.mjs); what moved
|
|
28
|
+
// is the RULES, not the crypto.
|
|
29
|
+
import { SdkError } from "./errors.mjs";
|
|
30
|
+
import * as engine from "./engine.mjs";
|
|
31
|
+
|
|
32
|
+
// THE ENGINE SURFACES, TAKEN THROUGH THE ONE SEAM (./engine.mjs) - exactly as the rest of the core does.
|
|
33
|
+
const { chainById, DEFAULT_CHAIN, MONERO } = engine.config;
|
|
34
|
+
const { fundOrderLive, newSecret, sendTransaction, readContract } = engine.evm;
|
|
35
|
+
const {
|
|
36
|
+
encodeClaim, encodeRefund, encodeMarkReady, encodeStatus, encodeT1, encodeTermsHash, encodeRead,
|
|
37
|
+
decodeUint64, decodeBytes32, decodeStatus,
|
|
38
|
+
} = engine.escrow;
|
|
39
|
+
// THE COUNTERPARTY GUARDS: whether a stand-in may be signed on this chain.
|
|
40
|
+
const { liveOrderSlotsVerdict, counterpartyVerdict } = engine.guards;
|
|
41
|
+
// THE HALF WORKER, THE ORDER CONTEXT, THE ORDER QUOTE AND THE SHARED CLAIM GAS LIMIT - all through the mirror,
|
|
42
|
+
// so the page and the package never keep a second copy of these rules.
|
|
43
|
+
const { createOrderWorker } = engine.orderClient;
|
|
44
|
+
const { orderContextString } = engine.orderContext;
|
|
45
|
+
const { requestOrderQuote, checkOrderQuoteShape } = engine.rfqSource;
|
|
46
|
+
const { CLAIM_GAS_LIMIT } = engine.claimGas;
|
|
47
|
+
|
|
48
|
+
// The factory address comes from the network registry: a second source of truth about addresses must not exist.
|
|
49
|
+
export function escrowFactoryAddress(chainSlug) {
|
|
50
|
+
const chain = chainById(chainSlug || DEFAULT_CHAIN);
|
|
51
|
+
const address = chain && chain.escrow && chain.escrow.address;
|
|
52
|
+
if (!address) throw new SdkError("bad-input", { field: "factory", chain: typeof chainSlug === "string" ? chainSlug : null });
|
|
53
|
+
return address;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// THE INTERFACE FEE CASHIER (#103): the address comes from the network registry, like the factory. The factory
|
|
57
|
+
// no longer creates the cashier - it arrives in the ORDER TERMS, and without it no quote under the order can be
|
|
58
|
+
// assembled.
|
|
59
|
+
export function escrowCashierAddress(chainSlug) {
|
|
60
|
+
const chain = chainById(chainSlug || DEFAULT_CHAIN);
|
|
61
|
+
const cashier = chain && chain.escrow && chain.escrow.cashier;
|
|
62
|
+
if (!cashier) throw new SdkError("bad-input", { field: "cashier", chain: typeof chainSlug === "string" ? chainSlug : null });
|
|
63
|
+
return cashier;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The full flow. onStage(stage, detail) is called on every step - the interface must show what is going on.
|
|
67
|
+
export async function runAtomicOrder({
|
|
68
|
+
chainSlug, locker, claimer, amountWei, amountLabel, providerId = null,
|
|
69
|
+
// THE DEPOSITOR'S SIGNATURE (issue #97, parts A/C): when given, the order is placed through
|
|
70
|
+
// createOrderAndFundByDepositor - any wallet may send the transaction, while the signer is recorded as the
|
|
71
|
+
// depositor.
|
|
72
|
+
depositorSignature = null,
|
|
73
|
+
claimWindowSeconds = 3600, readyWindowSeconds = 14400, salt: providedSalt, onStage,
|
|
74
|
+
// THE SUBSTITUTION POINTS FOR THE SAKE OF THE CHECK, and they are not decoration. The live flow could be
|
|
75
|
+
// neither run in a check nor covered otherwise: it requires a browser Worker and a signature in the wallet.
|
|
76
|
+
// Because of that two defects in a row appeared in it that neither the syntax nor the suite could see: the
|
|
77
|
+
// signature did not accept the mandatory fields, and after the fix a reference to a variable outside its scope
|
|
78
|
+
// remained - such things are visible only in operation. The defaults here are real: substitution is only in
|
|
79
|
+
// checks.
|
|
80
|
+
deps = {},
|
|
81
|
+
}) {
|
|
82
|
+
const makeWorker = deps.createOrderWorker || createOrderWorker;
|
|
83
|
+
const fundLive = deps.fundOrderLive || fundOrderLive;
|
|
84
|
+
const askQuote = deps.requestOrderQuote || requestOrderQuote;
|
|
85
|
+
const factory = escrowFactoryAddress(chainSlug);
|
|
86
|
+
const cashier = escrowCashierAddress(chainSlug);
|
|
87
|
+
const chain = chainById(chainSlug || DEFAULT_CHAIN);
|
|
88
|
+
|
|
89
|
+
// THE DEADLINES AND THE SALT ARE DECIDED HERE, BEFORE THE PROOF, AND NOWHERE ELSE.
|
|
90
|
+
//
|
|
91
|
+
// This is the very reason the context stopped being a string from the caller. The proof is bound to the order
|
|
92
|
+
// terms, and the terms include the deadlines and the salt. While the funding module decided them - that is,
|
|
93
|
+
// AFTER the computation - the proof was bound to one set of deadlines while a different set went into the
|
|
94
|
+
// contract: the check would fail at the counterparty, and on our own side everything would look healthy.
|
|
95
|
+
// The salt here is the same in meaning as before: it determines the escrow address, so repeating an order must
|
|
96
|
+
// come with the previous salt, otherwise a second escrow is created instead of the same one.
|
|
97
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
98
|
+
const readyBy = nowSec + readyWindowSeconds;
|
|
99
|
+
// t1 IS THE WINDOW AFTER readyBy, NOT "from now" (issue #88): the owner's claim window equals an hour AFTER the
|
|
100
|
+
// readiness deadline. The flat formula "now + claimWindowSeconds" at a 4-hour readyBy gave t1 EARLIER than
|
|
101
|
+
// readyBy, that is, an order created already closed (this is what check-atomic-flow caught).
|
|
102
|
+
const t1 = readyBy + claimWindowSeconds;
|
|
103
|
+
const salt = providedSalt || newSecret();
|
|
104
|
+
|
|
105
|
+
// THE CONTEXT IS ASSEMBLED FROM THE ORDER FIELDS - by the same code as the provider node's and any verifier's.
|
|
106
|
+
// The provider's half is derived from THESE SAME terms, so its proof will agree only here.
|
|
107
|
+
// ONE ORDER OBJECT - ONE SOURCE OF FIELDS. Both the proof context and the quote request to the provider are
|
|
108
|
+
// assembled from it. A second copy of these fields would diverge exactly where it costs the most: the provider
|
|
109
|
+
// would derive its half from one set of terms while the proof was computed over another.
|
|
110
|
+
//
|
|
111
|
+
// THERE IS NO CLAIMER HERE, AND THAT IS FUNDAMENTAL. The contract pays whoever is recorded as claimer, and at
|
|
112
|
+
// claim time the half is revealed by whoever claims. So only the PROVIDER can name the claimer: it holds the
|
|
113
|
+
// half and its address must receive the money. While the application substituted its own value, the provider
|
|
114
|
+
// got an order with a foreign address and could not claim the ETH for it - the half stayed with it while the
|
|
115
|
+
// money went to someone else. That is why the claimer comes in the quote, and the context is assembled AFTER
|
|
116
|
+
// the quote.
|
|
117
|
+
const orderBase = {
|
|
118
|
+
chainId: chain.chainId, factory, cashier, locker,
|
|
119
|
+
amount: String(amountWei), readyBy: String(readyBy), t1: String(t1), salt,
|
|
120
|
+
asset: "XMR", // what the user receives; not part of the context (it follows from the pair and network)
|
|
121
|
+
};
|
|
122
|
+
// THERE IS NO HASHLOCK AND ENVELOPE HERE ANY MORE. Under the scheme of doc 27 the order carries BOTH half
|
|
123
|
+
// commitments and BOTH public ed25519 points, and the half is published by the settlement itself
|
|
124
|
+
// (claim/refund). The secret that used to be placed in the recovery file is now MY HALF: it is exactly what is
|
|
125
|
+
// needed to claim the XMR.
|
|
126
|
+
const worker = makeWorker();
|
|
127
|
+
const say = (stage, detail) => onStage?.(stage, detail);
|
|
128
|
+
try {
|
|
129
|
+
// THE QUOTE FIRST, THEN YOUR OWN SIDE. The order of steps is set by the fact that the claimer is named by the
|
|
130
|
+
// provider: the half binding includes the claimer's address, so our own half must be computed over the
|
|
131
|
+
// context that is already fully known. It used to be the other way round, and the provider got an order with
|
|
132
|
+
// the address the application had chosen.
|
|
133
|
+
let counter = null;
|
|
134
|
+
let standIn = true;
|
|
135
|
+
let quote = null;
|
|
136
|
+
if (providerId) {
|
|
137
|
+
say("quote", "provider-quote");
|
|
138
|
+
quote = await askQuote({ providerId, order: orderBase });
|
|
139
|
+
checkOrderQuoteShape(quote);
|
|
140
|
+
// THE CLAIMER'S ADDRESS COMES FROM THE PROVIDER, and it is the one that goes into the contract. We check
|
|
141
|
+
// the shape here because it takes part in the binding further on: garbage in this field would break the
|
|
142
|
+
// proof.
|
|
143
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(String(quote.claimer || ""))) {
|
|
144
|
+
throw new SdkError("quote-refused", { step: "claimer" });
|
|
145
|
+
}
|
|
146
|
+
if (claimer && String(claimer).toLowerCase() !== String(quote.claimer).toLowerCase()) {
|
|
147
|
+
// TWO DIFFERENT RECIPIENTS: the ETH would go not to whoever holds the half.
|
|
148
|
+
throw new SdkError("quote-refused", { step: "claimer", why: "caller-mismatch" });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const claimerResolved = (quote && quote.claimer) || claimer;
|
|
152
|
+
if (!claimerResolved) {
|
|
153
|
+
throw new SdkError("bad-input", { field: "claimer" });
|
|
154
|
+
}
|
|
155
|
+
const order = { ...orderBase, claimer: claimerResolved };
|
|
156
|
+
const orderContext = orderContextString(order);
|
|
157
|
+
|
|
158
|
+
say("half", "side");
|
|
159
|
+
const mine = await worker.buildSide(orderContext, say);
|
|
160
|
+
say("half", "side-ready");
|
|
161
|
+
|
|
162
|
+
// The counterparty stand-in. Honestly: only a real counterparty with its own key gives a guarantee; a
|
|
163
|
+
// stand-in by definition guarantees nothing. Here it is needed to run the whole path in the demo.
|
|
164
|
+
// THE COUNTERPARTY SIDE: A REAL PROVIDER IF ONE IS SELECTED, otherwise the stand-in.
|
|
165
|
+
//
|
|
166
|
+
// WITH A REAL PROVIDER WE DO NOT LEARN ITS HALF - AND MUST NOT. Only the half commitment, the ed25519 point
|
|
167
|
+
// and the proof arrive in the quote; the half itself stays with it and is revealed on-chain only when it
|
|
168
|
+
// claims the ETH. That is exactly why the commitment is taken FROM THE QUOTE rather than computed by us:
|
|
169
|
+
// there is nothing to compute it from.
|
|
170
|
+
if (quote) {
|
|
171
|
+
// THE CONTEXT STRING IS CHECKED EXPLICITLY, although the proof is bound to it anyway: a mismatch must be
|
|
172
|
+
// called by its own name rather than look like "the proof did not agree".
|
|
173
|
+
if (String(quote.context) !== orderContext) {
|
|
174
|
+
throw new SdkError("quote-refused", { step: "context" });
|
|
175
|
+
}
|
|
176
|
+
// THE VIEW HALF COMES FROM THE PROVIDER: without it the shared address cannot be assembled and the arrival
|
|
177
|
+
// cannot be seen. The point is derived from it below - there is no cryptography in the main flow.
|
|
178
|
+
// THE PROVIDER'S POINTS COME WITHOUT THE "0x" PREFIX, WHILE THE CALLDATA ENCODER REQUIRES IT.
|
|
179
|
+
// The encoder (factory.js: word) accepts a decimal number or hex WITH the prefix and refuses "not a number
|
|
180
|
+
// and not hex" on 64 digits without one. So the points are normalised to one form HERE - at the boundary
|
|
181
|
+
// where the provider's data enters the order. IMPORTANT: we normalise both pub.ed and proof.XB, because the
|
|
182
|
+
// check compares them against each other, and different spellings of one point would look like a proof
|
|
183
|
+
// mismatch.
|
|
184
|
+
const with0x = (v) => (typeof v === "string" && v.length && !v.startsWith("0x") ? "0x" + v : v);
|
|
185
|
+
const quoteProof = quote.proof && typeof quote.proof === "object"
|
|
186
|
+
? { ...quote.proof, ...(quote.proof.XB ? { XB: with0x(String(quote.proof.XB)) } : {}) }
|
|
187
|
+
: quote.proof;
|
|
188
|
+
counter = { proof: quoteProof, edPub: with0x(quote.edPointClaimer), commitHalfClaimer: quote.commitHalfClaimer,
|
|
189
|
+
viewHalf: quote.viewHalfClaimer, providerId: quote.providerId };
|
|
190
|
+
standIn = false;
|
|
191
|
+
} else {
|
|
192
|
+
// THE STAND-IN IS COMPUTED OVER THE SAME CONTEXT AS THE ORDER. It used to append ":counterparty" to the
|
|
193
|
+
// string, and that turned the check into a ritual: the stand-in's proof was bound to a different string,
|
|
194
|
+
// that is, to nothing. Now exactly the path a real provider would take is checked.
|
|
195
|
+
const mock = await worker.buildSide(orderContext);
|
|
196
|
+
counter = { proof: mock.proof, edPub: mock.pub.ed, mock, viewHalf: mock.viewHalf, viewPoint: mock.viewPub };
|
|
197
|
+
}
|
|
198
|
+
// THE COUNTERPARTY CHECK REMAINS ONE AND IT IS THE MAIN ONE: the DLEQ proof in the context of this order.
|
|
199
|
+
// It confirms that both public points stand for ONE scalar - that is, that the half declared for the Monero
|
|
200
|
+
// address and the half by which the chain will compute are the same number. There is no envelope in the
|
|
201
|
+
// scheme any more, so there is nothing to open - and nothing to check except the proof.
|
|
202
|
+
// TOGETHER WITH IT, the fact is checked that the proven ed25519 point IS THE VERY ONE the contract will
|
|
203
|
+
// receive: without that check a correct proof would guarantee nothing about the chain.
|
|
204
|
+
say("verify", "dleq");
|
|
205
|
+
const verdict = await worker.verifyCounterparty(orderContext, {
|
|
206
|
+
proof: counter.proof, edPub: counter.edPub,
|
|
207
|
+
}, null);
|
|
208
|
+
if (!verdict.ok) throw new SdkError("quote-refused", { step: "proof", why: "rejected" });
|
|
209
|
+
|
|
210
|
+
// COMMITMENTS ON BOTH HALVES: our own is computed in the worker (only the hash goes out), the counterparty's
|
|
211
|
+
// either arrived in the quote or is computed over the stand-in's half. Exactly these values are checked by
|
|
212
|
+
// the contract in claim and refund and are included in termsHash.
|
|
213
|
+
const commitHalfLocker = (await worker.halfCommitment(mine.half)).commitment;
|
|
214
|
+
const commitHalfClaimer = counter.commitHalfClaimer || (await worker.halfCommitment(counter.mock.half)).commitment;
|
|
215
|
+
|
|
216
|
+
// THE SHARED MONERO ADDRESS - FROM THE HALVES, AND IT IS COMPUTED BEFORE THE MONEY IS LOCKED. The address used
|
|
217
|
+
// to be assembled from the maker's seed, that is, from the FULL key on its side: that is a custodial scheme,
|
|
218
|
+
// in which the other side can take the XMR at any moment. Now each side has its own half, and the address is
|
|
219
|
+
// their sum.
|
|
220
|
+
//
|
|
221
|
+
// We compute it BEFORE funding on purpose: the address is derived from the same points that will go into the
|
|
222
|
+
// contract, and if it cannot be assembled (the counterparty has neither a point nor a view half), we must
|
|
223
|
+
// learn about it before the user signs the deposit.
|
|
224
|
+
const otherViewPoint = counter.viewPoint || (counter.viewHalf ? (await worker.pointOf(counter.viewHalf)).ed : null);
|
|
225
|
+
if (!otherViewPoint) {
|
|
226
|
+
throw new SdkError("quote-refused", { step: "view-half" });
|
|
227
|
+
}
|
|
228
|
+
say("address", "joint-address");
|
|
229
|
+
const joint = await worker.jointAddress({
|
|
230
|
+
ownSpendHalf: mine.half, ownViewHalf: mine.viewHalf,
|
|
231
|
+
otherSpendPoint: counter.edPub, otherViewPoint,
|
|
232
|
+
network: (chain.monero && chain.monero.networkType) || MONERO.networkType,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// THE COUNTERPARTY STAND-IN ON A LIVE NETWORK IS A REFUSAL, NOT A SIGNATURE (the auditor's finding, review_02
|
|
236
|
+
// section 6.3 and block D1, item 5). Without a provider the counterparty side is assembled locally, the proof
|
|
237
|
+
// is checked against itself and proves nothing: this is the showcase mode. On a live network it would mean the
|
|
238
|
+
// money went against a non-existent side. THE TRIGGER IS EXACTLY THE LIVE NETWORK: on a testnet and in the
|
|
239
|
+
// local stand the stand-in is allowed and marked, otherwise the demonstration flow on arbitrum-sepolia would
|
|
240
|
+
// stop working.
|
|
241
|
+
// TWO DIFFERENT QUESTIONS THAT ARE EASY TO CONFUSE. "Is the escrow live" is about whether the deal is REAL
|
|
242
|
+
// (on sepolia the escrow is deployed, the transactions are real - the deal is real). Here a different thing
|
|
243
|
+
// is asked: whether the MONEY IN THE NETWORK IS REAL, that is, whether a made-up side can be slipped in. On
|
|
244
|
+
// sepolia the contract is deployed but the money is play: the stand-in is fine there, otherwise the
|
|
245
|
+
// demonstration flow would stall. On a live network the same trick would mean the funds are locked against a
|
|
246
|
+
// side that does not exist. Hence the name: this is not "a live network in general" and not a network named
|
|
247
|
+
// mainnet, but a network where the money is real.
|
|
248
|
+
const realMoney = Boolean(chain.escrow && chain.escrow.mode === "live" && !chain.testnet);
|
|
249
|
+
const counterparty = counterpartyVerdict({
|
|
250
|
+
standIn, liveChain: realMoney,
|
|
251
|
+
network: chain.name || chainSlug || null, chainId: chain.chainId || null,
|
|
252
|
+
});
|
|
253
|
+
if (!counterparty.ok) throw new SdkError("bad-input", { field: "counterparty", why: "stand-in-on-live-network" });
|
|
254
|
+
|
|
255
|
+
// ONE TRANSACTION, NOT TWO: funding goes through createOrderAndFund, which creates the escrow together with
|
|
256
|
+
// the deposit. The label on the button remained from the two-step path and lied about the number of
|
|
257
|
+
// signatures.
|
|
258
|
+
say("lock", "fund-order");
|
|
259
|
+
const funded = await fundLive({
|
|
260
|
+
// THE CLAIMER GOES INTO THE CONTRACT EXACTLY THE ONE THAT ENTERED THE BINDING: it came from the provider
|
|
261
|
+
// (or from the caller in a simulation) and already participates in the context over which our half was
|
|
262
|
+
// computed.
|
|
263
|
+
factory, locker, claimer: order.claimer,
|
|
264
|
+
// WE PASS THE DEPOSITOR'S SIGNATURE FURTHER instead of losing it halfway: without it the funding would go
|
|
265
|
+
// the previous way, and the relayer would get a NotLocker refusal only after the person had signed.
|
|
266
|
+
depositorSignature,
|
|
267
|
+
// THE DEADLINES GO INTO THE CONTRACT EXACTLY THE ONES THAT ENTERED THE CONTEXT. This is not duplication but
|
|
268
|
+
// a condition: diverge by even a second - and the proof by which the provider confirms the half stops
|
|
269
|
+
// agreeing.
|
|
270
|
+
readyBy, t1, readyWindowSeconds, claimWindowSeconds,
|
|
271
|
+
commitHalfLocker, commitHalfClaimer,
|
|
272
|
+
// The ed25519 points go into the order explicitly: the other side assembles the shared address from them
|
|
273
|
+
// and checks the proof EXACTLY against the on-chain fields, not against what arrived in the quote (doc 27
|
|
274
|
+
// section 11).
|
|
275
|
+
edPointLocker: mine.pub.ed, edPointClaimer: counter.edPub,
|
|
276
|
+
// THE DEPOSITOR'S VIEW POINT: from it the claimer assembles the address FROM THE ORDER FIELDS. Without it
|
|
277
|
+
// it does not know where to send the XMR - and learns about it only after the money is deposited.
|
|
278
|
+
edViewPointLocker: mine.viewPub,
|
|
279
|
+
salt, // our swap salt: see funding.js
|
|
280
|
+
amountLabel, // the ready amount string for the label: formatting lives in the UI
|
|
281
|
+
amountWei,
|
|
282
|
+
onStep: (text) => say("lock", text),
|
|
283
|
+
});
|
|
284
|
+
// WE HAND OUT OUR HALF: it is what claims the XMR and what must go into the recovery file. There used to be a
|
|
285
|
+
// separate test secret here - along with the hashlock it left the scheme.
|
|
286
|
+
// WE RETURN EVERYTHING NEEDED AFTER THE DEAL, and nothing extra.
|
|
287
|
+
return {
|
|
288
|
+
escrow: funded.escrow,
|
|
289
|
+
// THE FUNDING TRANSACTION HASH: without it the swap record cannot show what exactly paid for the order, and
|
|
290
|
+
// the run cannot verify that the VERY transaction it saw was paid.
|
|
291
|
+
createHash: (funded && funded.createHash) || null,
|
|
292
|
+
// The salt goes out: it determines the escrow address, and the swap record must keep it, otherwise
|
|
293
|
+
// repeating the order creates a second escrow instead of the previous one.
|
|
294
|
+
salt: (funded && funded.salt) || salt,
|
|
295
|
+
// THE FEE ACTUALLY DEPOSITED OVER THE ORDER AMOUNT: it must be kept - from it the swap record shows how
|
|
296
|
+
// much was actually deposited, and this is the only place where the value is known.
|
|
297
|
+
feeWei: (funded && funded.feeWei) || 0n,
|
|
298
|
+
feeTotalWei: (funded && funded.feeTotalWei) || null,
|
|
299
|
+
feeBps: (funded && funded.feeBps) || 0,
|
|
300
|
+
half: mine.half,
|
|
301
|
+
counterHalf: standIn ? counter.mock.half : null,
|
|
302
|
+
// THE VIEW HALVES: our own and the counterparty's. Without them the address cannot be assembled and the
|
|
303
|
+
// version 3 recovery file cannot be filled in, and without the file the user will not claim the XMR after
|
|
304
|
+
// losing the tab.
|
|
305
|
+
viewHalf: mine.viewHalf,
|
|
306
|
+
commitHalfLocker,
|
|
307
|
+
commitHalfClaimer,
|
|
308
|
+
edPointLocker: mine.pub.ed,
|
|
309
|
+
edPointClaimer: counter.edPub,
|
|
310
|
+
// THE DEPOSITOR'S VIEW POINT goes into the record: without it there is nothing to compare the chain slot
|
|
311
|
+
// edViewPointLocker against before signing - and it is part of the address the claimer will see.
|
|
312
|
+
edViewPointLocker: mine.viewPub,
|
|
313
|
+
// THE COUNTERPARTY'S VIEW HALF: the stand-in has it, a real provider does NOT, and this is a known gap of
|
|
314
|
+
// the protocol, not a trifle: without its view half the shared Monero address cannot be assembled. While
|
|
315
|
+
// the address in the application is built the previous way, the path does not break, but a quote under the
|
|
316
|
+
// order must carry the view point - written in doc 21, D14.3.
|
|
317
|
+
// ONE KIND OF HALF ON THE OUTPUT, and this is not pedantry: the stand-in's half is a number, the provider's
|
|
318
|
+
// arrives as a string, and the consumer (the recovery file and the sweep) must not guess which it was
|
|
319
|
+
// given. Next to the spend half, which is also a number.
|
|
320
|
+
counterViewHalf: standIn ? counter.mock.viewHalf : BigInt(counter.viewHalf),
|
|
321
|
+
counterViewPoint: otherViewPoint,
|
|
322
|
+
// THE SHARED ADDRESS: where the maker sends the XMR. Nobody can spend from it before the half is revealed.
|
|
323
|
+
moneroAddress: joint.address,
|
|
324
|
+
moneroSpendPub: joint.spendPub,
|
|
325
|
+
moneroViewPub: joint.viewPub,
|
|
326
|
+
standIn,
|
|
327
|
+
providerId: providerId || null,
|
|
328
|
+
// THE QUOTE ID IS NEEDED OUTSIDE THE FLOW: the provider node links the created escrow to its own record by
|
|
329
|
+
// it, and only after that can it claim the ETH. Without this link the half stays unclaimed and the node's
|
|
330
|
+
// order_ref is empty (the regular payout does not reach the order).
|
|
331
|
+
quoteId: (quote && quote.id) || null,
|
|
332
|
+
};
|
|
333
|
+
} finally {
|
|
334
|
+
worker.stop();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- The second half of the order's life: claim or refund ---------------------------------------
|
|
339
|
+
//
|
|
340
|
+
// Both operations are ordinary transactions in the user's wallet, and both are called ONLY from the interface:
|
|
341
|
+
// no automatic claiming of funds, because the secret grants the right to the money.
|
|
342
|
+
|
|
343
|
+
// The order state is read by a status() call, not from logs - and the previous explanation here was WRONG. It
|
|
344
|
+
// claimed that the escrow has a SealedHalf event but no "claimed"/"refunded" events. The contract has NO
|
|
345
|
+
// SealedHalf event at all, while the settlement events EXIST: HalfRevealed(bytes32 half, bool byClaimer)
|
|
346
|
+
// (NinseiEscrow.sol:109; the byClaimer flag distinguishes a claim from a refund), OrderTerms (:111-117) and
|
|
347
|
+
// Ready (:119). The reason for polling is different, and it is also in the contract:
|
|
348
|
+
// * THE DEPOSIT HAS NO EVENT: the funded flag is set in the constructor together with OrderTerms, so there is
|
|
349
|
+
// no separate "deposited" in the logs, and that is the first of the order's states;
|
|
350
|
+
// * status() (:255-257) exists "for the front end and the watchtower" and returns all four values in one call -
|
|
351
|
+
// funded, claimed, refunded, remainder - by the address already known from the swap record.
|
|
352
|
+
// Hence: subscribing to events is the business of whoever keeps the order journal, while the state of a
|
|
353
|
+
// particular order is read by a call.
|
|
354
|
+
export async function orderStatus(escrow, deps = {}) {
|
|
355
|
+
if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
|
|
356
|
+
// READER SEAM: the caller may bring its own chain read (deps.call), exactly as the actions do. The default is
|
|
357
|
+
// the engine read. The page goes through the bridge, which hands in the wallet's own reader, so the state is
|
|
358
|
+
// read by the same wallet that will sign.
|
|
359
|
+
const call = deps.call || readContract;
|
|
360
|
+
const raw = await call({ to: escrow, data: encodeStatus() });
|
|
361
|
+
return decodeStatus(raw);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// THE ORDER DEADLINE IS ONE - t1. Before it the claimer takes the ETH by its half and only with the ready mark
|
|
365
|
+
// (:215); from t1 claiming is forbidden (:216), and anyone may refund by PRESENTING THE DEPOSITOR'S HALF
|
|
366
|
+
// (:232-235) - the watchtower does not have it, and that limitation is named in doc 24 section 1. The deadline
|
|
367
|
+
// is read from the contract: it is immutable and exists only on-chain. It is needed for live deals created
|
|
368
|
+
// before we started storing the deadlines ourselves: without reading, the counter would show the time invented by
|
|
369
|
+
// the simulator. The contract no longer has t0() (decision 2026-09-16, doc 26).
|
|
370
|
+
export async function orderDeadlines(escrow, deps = {}) {
|
|
371
|
+
if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
|
|
372
|
+
// THE SAME READER SEAM AS orderStatus: the deadline must be read by the wallet that will sign, not by a second
|
|
373
|
+
// provider.
|
|
374
|
+
const call = deps.call || readContract;
|
|
375
|
+
const [raw1, rawH, rawReadyBy] = await Promise.all([
|
|
376
|
+
call({ to: escrow, data: encodeT1() }),
|
|
377
|
+
call({ to: escrow, data: encodeTermsHash() }),
|
|
378
|
+
// THE READY-MARK DEADLINE IS READ HERE TOO: without it the screen cannot say that the person is already in
|
|
379
|
+
// the dead zone (the mark is closed, claiming is closed, refunding is not open yet) - and that is exactly
|
|
380
|
+
// where the deal hangs silently.
|
|
381
|
+
call({ to: escrow, data: encodeRead("readyBy") }),
|
|
382
|
+
]);
|
|
383
|
+
// An empty eth_call response means there is NO CODE at the address: the contract is not deployed. This happens
|
|
384
|
+
// when the createOrder transaction did not go through (for example the wallet rejected it over the fee) while
|
|
385
|
+
// the predicted address is already written into the swap and looks real. This is NOT a call error, and the
|
|
386
|
+
// reason must be named, not "a short response".
|
|
387
|
+
for (const [raw, what] of [[raw1, "t1()"]]) {
|
|
388
|
+
if (String(raw || "").replace(/^0x/, "").length === 0) {
|
|
389
|
+
// NOT A CALL ERROR: the predicted address exists only on paper until the createOrder transaction is
|
|
390
|
+
// confirmed. The refusal names the reason instead of "a short response".
|
|
391
|
+
throw new SdkError("bad-input", { field: "escrow", why: "no-contract" });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
t1: decodeUint64(raw1, "t1()"),
|
|
396
|
+
// The terms hash from the chain: the recovery file is bound to a particular order by it.
|
|
397
|
+
termsHash: decodeBytes32(rawH, "termsHash()"),
|
|
398
|
+
// readyBy in seconds: the ready-mark boundary, one per order.
|
|
399
|
+
readyBy: decodeUint64(rawReadyBy, "readyBy()"),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// THE LIVE ORDER SLOTS: both sides' points and both halves' commitments, READ FROM THE ESCROW rather than taken
|
|
404
|
+
// from the swap record. The record is what we thought; the chain is what the order actually is, and before
|
|
405
|
+
// signing they must agree.
|
|
406
|
+
export async function orderLiveSlots(escrow, deps = {}) {
|
|
407
|
+
// SEAM: the caller decides what reads the chain (`deps.call`), the default is the engine read. It is needed so
|
|
408
|
+
// that the slot check goes through the same wallet as the signature rather than past it.
|
|
409
|
+
const call = deps.call || readContract;
|
|
410
|
+
if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
|
|
411
|
+
const keys = ["edPointLocker", "edPointClaimer", "edViewPointLocker", "commitHalfLocker", "commitHalfClaimer"];
|
|
412
|
+
try {
|
|
413
|
+
const raws = await Promise.all(keys.map((k) => call({ to: escrow, data: encodeRead(k) })));
|
|
414
|
+
const slots = {};
|
|
415
|
+
keys.forEach((k, i) => { slots[k] = decodeBytes32(raws[i], k + "()"); });
|
|
416
|
+
return { slots };
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return { error: String((e && e.message) || e) };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// A REFUSAL BEFORE SIGNING: the on-chain order must hold exactly the points and commitments the page assembled
|
|
423
|
+
// and on which the proof agreed. The check costs one read, and without it the signature goes out under an
|
|
424
|
+
// unknown order: foreign points mean the shared Monero address was assembled from other halves, and the XMR
|
|
425
|
+
// cannot be claimed after settlement. Expectations are mandatory: a check without them verifies nothing, so this
|
|
426
|
+
// is an error, not a skip.
|
|
427
|
+
export async function assertOrderSlots(escrow, expect, what = "signature", deps = {}) {
|
|
428
|
+
if (!expect) throw new SdkError("bad-input", { field: "expect" });
|
|
429
|
+
const read = await orderLiveSlots(escrow, deps);
|
|
430
|
+
const verdict = liveOrderSlotsVerdict({ slots: read.slots, readError: read.error, expect });
|
|
431
|
+
if (verdict.blocks) throw new SdkError("contract-reverted", { step: what, why: "slots-mismatch" });
|
|
432
|
+
return verdict;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Claim the ETH by the CLAIMER'S half. The field name in the contract is halfClaimer (:213); the former word
|
|
436
|
+
// "secret" is a synonym of the same value (the half serves as the preimage of the commitment), while "hashlock"
|
|
437
|
+
// lied here: the current contract has NO hashlock field. Instead it has two half commitments -
|
|
438
|
+
// commitHalfClaimer and commitHalfLocker (:74-75), and claim compares the half against the first (:217).
|
|
439
|
+
// In production this is the second half of the Monero spend key, so claiming and revealing the half are one
|
|
440
|
+
// action: the half is published by whoever claims, and it is also obtained by the other side (HalfRevealed, :222).
|
|
441
|
+
export async function claimOrder({ escrow, halfClaimer, expect, onStep, deps = {} }) {
|
|
442
|
+
if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
|
|
443
|
+
if (!halfClaimer) throw new SdkError("bad-input", { field: "halfClaimer" });
|
|
444
|
+
// SLOT CHECK BEFORE SIGNING: a claim reveals the half, and it must be revealed in the VERY order whose
|
|
445
|
+
// commitments we know. Beyond this point the decision cannot be undone.
|
|
446
|
+
await assertOrderSlots(escrow, expect, "claim", deps);
|
|
447
|
+
onStep?.("confirm-claim");
|
|
448
|
+
// THE CLAIM GAS LIMIT COMES FROM THE SHARED MODULE, NOT A LITERAL HERE (issue #127): the node lays exactly
|
|
449
|
+
// this limit into the gas gift. It is set explicitly and with a margin: claim has the same trap as order
|
|
450
|
+
// creation - the wallet estimates gas over a state that no longer exists on the chain (the same reason is
|
|
451
|
+
// named in the contract, NinseiEscrow.sol:137-138), and the call costs tens of thousands of gas.
|
|
452
|
+
const hash = await (deps.send || sendTransaction)({ to: escrow, data: encodeClaim(halfClaimer), gas: CLAIM_GAS_LIMIT });
|
|
453
|
+
return { hash };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Refund after the settlement deadline t1. It requires the DEPOSITOR'S half: the contract compares its hash
|
|
457
|
+
// against the commitHalfLocker commitment (:235), publishes it and always sends the money to the depositor
|
|
458
|
+
// (:242). Hence a consequence to be aware of: it is not "anyone" who can refund, but whoever holds the
|
|
459
|
+
// depositor's half - the depositor itself or whoever it gave it to. The contract does not check the sender
|
|
460
|
+
// (:228-231), but the freedom to call is not the freedom to sign: the right to run the transaction is given by
|
|
461
|
+
// the half itself.
|
|
462
|
+
export async function refundOrder({ escrow, halfLocker, onStep, expect = null, deps = {} }) {
|
|
463
|
+
if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
|
|
464
|
+
if (!halfLocker) throw new SdkError("bad-input", { field: "halfLocker" });
|
|
465
|
+
onStep?.("confirm-refund");
|
|
466
|
+
// THE SLOT CHECK HERE TOO, WHEN EXPECTATIONS ARE GIVEN. A refund REVEALS the half - that is, it is as
|
|
467
|
+
// irreversible as a claim, and signing it under an order whose commitments we have not checked is forbidden
|
|
468
|
+
// for the same reason. Expectations are optional for compatibility with previous calls (the page calls without
|
|
469
|
+
// them); the core always passes them.
|
|
470
|
+
if (expect) await assertOrderSlots(escrow, expect, "refund", deps);
|
|
471
|
+
const hash = await (deps.send || sendTransaction)({ to: escrow, data: encodeRefund(halfLocker), gas: 250_000n });
|
|
472
|
+
return { hash };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Readiness: "I saw the XMR on the shared address, claiming is allowed". Without it the contract FORBIDS
|
|
476
|
+
// claiming (:215) - and this is protection, not a formality: the contract does not see Monero and would pay on
|
|
477
|
+
// the deadline alone to someone who sent nothing. The party that deposited the ETH sets it, and only after it
|
|
478
|
+
// has itself seen the XMR lock. THE MARK WINDOW IS LIMITED, and this matters for the moment of the call: only on
|
|
479
|
+
// a funded order, only before readyBy and only once (:200-204). After readyBy without the mark the order closes
|
|
480
|
+
// by a refund (decision 30.09.2026): the claim window did not open, so claim is forbidden while refund opens
|
|
481
|
+
// immediately. There used to be a dead zone here: neither claim (needs ready) nor refund (needs the deadline)
|
|
482
|
+
// would go through.
|
|
483
|
+
export async function markReadyOrder({ escrow, expect, onStep, deps = {} }) {
|
|
484
|
+
if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
|
|
485
|
+
// SLOT CHECK BEFORE SIGNING: ready() grants the claimer settlement at its own address, and the grant is given
|
|
486
|
+
// to the VERY order whose points and commitments we have checked. A mark on a foreign order is a signature
|
|
487
|
+
// under what we did not assemble.
|
|
488
|
+
await assertOrderSlots(escrow, expect, "mark-ready", deps);
|
|
489
|
+
onStep?.("confirm-mark-ready");
|
|
490
|
+
const hash = await (deps.send || sendTransaction)({ to: escrow, data: encodeMarkReady(), gas: 120_000n });
|
|
491
|
+
return { hash };
|
|
492
|
+
}
|