@bananapus/nana-sdk-core 2.15.0 → 2.17.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/dist/cjs/review/contractWrite.d.ts +9 -1
- package/dist/cjs/review/contractWrite.d.ts.map +1 -1
- package/dist/cjs/review/contractWrite.js +20 -5
- package/dist/cjs/review/contractWrite.js.map +1 -1
- package/dist/cjs/review/decode.d.ts +138 -0
- package/dist/cjs/review/decode.d.ts.map +1 -0
- package/dist/cjs/review/decode.js +1118 -0
- package/dist/cjs/review/decode.js.map +1 -0
- package/dist/cjs/review/index.d.ts +2 -0
- package/dist/cjs/review/index.d.ts.map +1 -1
- package/dist/cjs/review/index.js +2 -0
- package/dist/cjs/review/index.js.map +1 -1
- package/dist/cjs/review/receipt.d.ts +32 -0
- package/dist/cjs/review/receipt.d.ts.map +1 -0
- package/dist/cjs/review/receipt.js +57 -0
- package/dist/cjs/review/receipt.js.map +1 -0
- package/dist/cjs/review/relayr.d.ts +380 -0
- package/dist/cjs/review/relayr.d.ts.map +1 -0
- package/dist/cjs/review/relayr.js +919 -0
- package/dist/cjs/review/relayr.js.map +1 -0
- package/dist/cjs/review/simulation.d.ts +47 -0
- package/dist/cjs/review/simulation.d.ts.map +1 -0
- package/dist/cjs/review/simulation.js +142 -0
- package/dist/cjs/review/simulation.js.map +1 -0
- package/dist/cjs/v6/feeBuyback.d.ts +27 -0
- package/dist/cjs/v6/feeBuyback.d.ts.map +1 -1
- package/dist/cjs/v6/feeBuyback.js +66 -0
- package/dist/cjs/v6/feeBuyback.js.map +1 -1
- package/dist/esm/review/contractWrite.d.ts +9 -1
- package/dist/esm/review/contractWrite.d.ts.map +1 -1
- package/dist/esm/review/contractWrite.js +19 -5
- package/dist/esm/review/contractWrite.js.map +1 -1
- package/dist/esm/review/decode.d.ts +138 -0
- package/dist/esm/review/decode.d.ts.map +1 -0
- package/dist/esm/review/decode.js +1102 -0
- package/dist/esm/review/decode.js.map +1 -0
- package/dist/esm/review/index.d.ts +2 -0
- package/dist/esm/review/index.d.ts.map +1 -1
- package/dist/esm/review/index.js +2 -0
- package/dist/esm/review/index.js.map +1 -1
- package/dist/esm/review/receipt.d.ts +32 -0
- package/dist/esm/review/receipt.d.ts.map +1 -0
- package/dist/esm/review/receipt.js +51 -0
- package/dist/esm/review/receipt.js.map +1 -0
- package/dist/esm/review/relayr.d.ts +380 -0
- package/dist/esm/review/relayr.d.ts.map +1 -0
- package/dist/esm/review/relayr.js +894 -0
- package/dist/esm/review/relayr.js.map +1 -0
- package/dist/esm/review/simulation.d.ts +47 -0
- package/dist/esm/review/simulation.d.ts.map +1 -0
- package/dist/esm/review/simulation.js +137 -0
- package/dist/esm/review/simulation.js.map +1 -0
- package/dist/esm/v6/feeBuyback.d.ts +27 -0
- package/dist/esm/v6/feeBuyback.d.ts.map +1 -1
- package/dist/esm/v6/feeBuyback.js +62 -0
- package/dist/esm/v6/feeBuyback.js.map +1 -1
- package/package.json +11 -1
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
import { decodeFunctionData, encodeFunctionData, isAddress, keccak256, } from "viem";
|
|
2
|
+
import { erc2771ForwarderAbi, jbContractAddress, } from "../generated/juicebox.js";
|
|
3
|
+
import { simulateStateChangingTransaction } from "./simulation.js";
|
|
4
|
+
// Relayr runs a bundle of transactions on several chains for one prepaid
|
|
5
|
+
// native payment on one of them. Everything its API returns is untrusted:
|
|
6
|
+
// these helpers bind a quote to the exact transactions that were posted,
|
|
7
|
+
// authenticate the payment against Relayr's immutable payment contract, and
|
|
8
|
+
// prove payments and destination transactions from the chain itself. None of
|
|
9
|
+
// them signs or sends.
|
|
10
|
+
/** Relayr's HTTP API. */
|
|
11
|
+
export const RELAYR_API = "https://api.relayr.ba5ed.com";
|
|
12
|
+
/**
|
|
13
|
+
* Relayr's immutable prepaid-native payment contract. A quote is untrusted
|
|
14
|
+
* HTTP input, so paying any other target or calldata would turn the quote
|
|
15
|
+
* service into a wallet transaction oracle.
|
|
16
|
+
*/
|
|
17
|
+
export const RELAYR_PAYMENT_ADDRESS = "0x1c05f7841379d4393574c0ffa17908ec40ffd97d";
|
|
18
|
+
/**
|
|
19
|
+
* The payment function's selector. Its arguments are the bundle UUID as a
|
|
20
|
+
* right-padded bytes16 word and a uint40 deadline.
|
|
21
|
+
*/
|
|
22
|
+
export const RELAYR_PAYMENT_SELECTOR = "0x103903a7";
|
|
23
|
+
/** keccak256 of the payment contract's runtime code. */
|
|
24
|
+
export const RELAYR_PAYMENT_CODE_HASH = "0x6006b5acadb4cd60aa5c00cb844c34563e182dff83d4f4ff4fde226f7df16fa6";
|
|
25
|
+
/** The token Relayr quotes for a payment in the chain's native currency. */
|
|
26
|
+
export const RELAYR_NATIVE_TOKEN = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
|
|
27
|
+
/** The gas a payment is simulated and sent with. */
|
|
28
|
+
export const RELAYR_PAYMENT_GAS = 150000n;
|
|
29
|
+
/**
|
|
30
|
+
* How long a signed ERC-2771 ForwardRequest stays executable: 47 hours from
|
|
31
|
+
* signing. The forwarder rejects it afterwards, even if the bundle was paid.
|
|
32
|
+
*/
|
|
33
|
+
export const RELAYR_FORWARDER_DEADLINE_SECONDS = 47 * 60 * 60;
|
|
34
|
+
/** ERC2771Context's view on a destination contract. */
|
|
35
|
+
export const TRUSTED_FORWARDER_ABI = [
|
|
36
|
+
{
|
|
37
|
+
type: "function",
|
|
38
|
+
name: "isTrustedForwarder",
|
|
39
|
+
stateMutability: "view",
|
|
40
|
+
inputs: [{ name: "forwarder", type: "address" }],
|
|
41
|
+
outputs: [{ type: "bool" }],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
/** EIP-712 types of an ERC2771Forwarder ForwardRequest. */
|
|
45
|
+
export const FORWARD_REQUEST_TYPES = {
|
|
46
|
+
ForwardRequest: [
|
|
47
|
+
{ name: "from", type: "address" },
|
|
48
|
+
{ name: "to", type: "address" },
|
|
49
|
+
{ name: "value", type: "uint256" },
|
|
50
|
+
{ name: "gas", type: "uint256" },
|
|
51
|
+
{ name: "nonce", type: "uint256" },
|
|
52
|
+
{ name: "deadline", type: "uint48" },
|
|
53
|
+
{ name: "data", type: "bytes" },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
const MAINNETS = [1, 10, 8453, 42161];
|
|
57
|
+
const TESTNETS = [11155111, 11155420, 84532, 421614];
|
|
58
|
+
const MAX_UINT256 = (1n << 256n) - 1n;
|
|
59
|
+
const PAYMENT_CODE_MAX_BYTES = 2_048;
|
|
60
|
+
const BUNDLE_READ_TIMEOUT_MS = 15_000;
|
|
61
|
+
const HTTP_DETAIL_CHARACTERS = 240;
|
|
62
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
63
|
+
const TRANSACTION_HASH = /^0x[0-9a-f]{64}$/iu;
|
|
64
|
+
// RFC 3339 with an explicit offset. A time without one is read in the
|
|
65
|
+
// machine's timezone, which would make the same quote pass or fail by place.
|
|
66
|
+
const DEADLINE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/iu;
|
|
67
|
+
const UNBOUND = "Relayr did not bind every quoted transaction to a unique ID. Nothing was paid.";
|
|
68
|
+
const UNRETURNED = "Relayr did not return the quoted transactions. Nothing was paid.";
|
|
69
|
+
const NO_PROOF = "This saved Relayr bundle lacks exact destination proof. Keep it pending and verify the original transactions; do not pay again.";
|
|
70
|
+
const NOT_IDENTIFIED = "Relayr has not identified every exact destination transaction. Keep checking the original bundle; do not pay again.";
|
|
71
|
+
const EXPIRED = "This Relayr quote expired. Review the action again for a new quote.";
|
|
72
|
+
const UNKNOWN_PAYMENT = "Relayr has not said whether this bundle is paid. Do not pay again yet; check it later.";
|
|
73
|
+
/**
|
|
74
|
+
* The chain or Relayr contradicts the expected transaction: this evidence can
|
|
75
|
+
* never prove it. Keep the action pending and never pay for it again.
|
|
76
|
+
*/
|
|
77
|
+
export class RelayrProofError extends Error {
|
|
78
|
+
constructor() {
|
|
79
|
+
super(...arguments);
|
|
80
|
+
this.name = "RelayrProofError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The payment at `hash` is exactly the reviewed payment, canonically included,
|
|
85
|
+
* and reverted, so that one transaction paid nothing. It does not show that
|
|
86
|
+
* the bundle is unpaid: the payment contract keeps no state, and Relayr keeps
|
|
87
|
+
* every payment it receives for a bundle, so another payment may have funded
|
|
88
|
+
* it. Only {@link requireRelayrPaymentRetry} clears the quote to be paid
|
|
89
|
+
* again.
|
|
90
|
+
*/
|
|
91
|
+
export class RelayrPaymentRevertedError extends RelayrProofError {
|
|
92
|
+
constructor(message, hash, chainId) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.hash = hash;
|
|
95
|
+
this.chainId = chainId;
|
|
96
|
+
this.name = "RelayrPaymentRevertedError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The destination at `hash` is exactly the signed call, canonically included,
|
|
101
|
+
* and reverted: the call failed on its chain. Whether its authorization can
|
|
102
|
+
* still be used again (an unchanged forwarder nonce) is the caller's to check.
|
|
103
|
+
*/
|
|
104
|
+
export class RelayrDestinationRevertedError extends RelayrProofError {
|
|
105
|
+
constructor(message, hash, chainId) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.hash = hash;
|
|
108
|
+
this.chainId = chainId;
|
|
109
|
+
this.name = "RelayrDestinationRevertedError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Like a native error cause, but set on ES2021 and never enumerable: an RPC error's URL can carry a key. */
|
|
113
|
+
function errorWithCause(message, cause) {
|
|
114
|
+
const error = new Error(message);
|
|
115
|
+
Object.defineProperty(error, "cause", {
|
|
116
|
+
value: cause,
|
|
117
|
+
writable: true,
|
|
118
|
+
enumerable: false,
|
|
119
|
+
configurable: true,
|
|
120
|
+
});
|
|
121
|
+
return error;
|
|
122
|
+
}
|
|
123
|
+
function isAddressLike(value) {
|
|
124
|
+
return typeof value === "string" && isAddress(value, { strict: false });
|
|
125
|
+
}
|
|
126
|
+
function sameAddress(value, address) {
|
|
127
|
+
return isAddressLike(value) && value.toLowerCase() === address.toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
function isHexBytes(value) {
|
|
130
|
+
return typeof value === "string" && /^0x(?:[0-9a-f]{2})*$/iu.test(value);
|
|
131
|
+
}
|
|
132
|
+
/** A uint256 given as decimal or 0x-hex digits, a safe integer or a bigint. */
|
|
133
|
+
function uint256(value) {
|
|
134
|
+
let parsed;
|
|
135
|
+
if (typeof value === "bigint")
|
|
136
|
+
parsed = value;
|
|
137
|
+
else if (typeof value === "number" && Number.isSafeInteger(value)) {
|
|
138
|
+
parsed = BigInt(value);
|
|
139
|
+
}
|
|
140
|
+
else if (typeof value === "string" &&
|
|
141
|
+
/^(?:0x[0-9a-f]+|\d+)$/iu.test(value)) {
|
|
142
|
+
parsed = BigInt(value);
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
return null;
|
|
146
|
+
return parsed >= 0n && parsed <= MAX_UINT256 ? parsed : null;
|
|
147
|
+
}
|
|
148
|
+
function uuidOf(value) {
|
|
149
|
+
if (typeof value !== "string")
|
|
150
|
+
return null;
|
|
151
|
+
const uuid = value.toLowerCase();
|
|
152
|
+
return UUID.test(uuid) ? uuid : null;
|
|
153
|
+
}
|
|
154
|
+
function readEntry(entry) {
|
|
155
|
+
if (!entry || typeof entry !== "object")
|
|
156
|
+
return null;
|
|
157
|
+
const { chain, target, data, value } = entry;
|
|
158
|
+
const amount = uint256(value);
|
|
159
|
+
return typeof chain === "number" &&
|
|
160
|
+
Number.isSafeInteger(chain) &&
|
|
161
|
+
chain > 0 &&
|
|
162
|
+
isAddressLike(target) &&
|
|
163
|
+
isHexBytes(data) &&
|
|
164
|
+
amount !== null
|
|
165
|
+
? { chain, target, data, value: amount }
|
|
166
|
+
: null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Whether a record's request is the posted transaction: the same chain,
|
|
170
|
+
* target, calldata and value, and the same virtual nonce (always when
|
|
171
|
+
* `nonce` is "required", else only when both carry one).
|
|
172
|
+
*/
|
|
173
|
+
function isRequestFor(request, entry, nonce) {
|
|
174
|
+
const read = readEntry(request);
|
|
175
|
+
const expected = readEntry(entry);
|
|
176
|
+
if (!read || !expected)
|
|
177
|
+
return false;
|
|
178
|
+
const quoted = request.virtual_nonce;
|
|
179
|
+
return (read.chain === expected.chain &&
|
|
180
|
+
read.target.toLowerCase() === expected.target.toLowerCase() &&
|
|
181
|
+
read.data.toLowerCase() === expected.data.toLowerCase() &&
|
|
182
|
+
read.value === expected.value &&
|
|
183
|
+
(nonce === "required"
|
|
184
|
+
? quoted === entry.virtual_nonce
|
|
185
|
+
: typeof quoted !== "number" ||
|
|
186
|
+
typeof entry.virtual_nonce !== "number" ||
|
|
187
|
+
quoted === entry.virtual_nonce));
|
|
188
|
+
}
|
|
189
|
+
/** Relayr runs bundles on Ethereum, Optimism, Base and Arbitrum, and on their Sepolia testnets. */
|
|
190
|
+
export function relayrSupportsChain(chainId) {
|
|
191
|
+
return MAINNETS.includes(chainId) || TESTNETS.includes(chainId);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* One destination per chain, every one in a single network family: all
|
|
195
|
+
* mainnets or all testnets.
|
|
196
|
+
*/
|
|
197
|
+
export function relayrSupportsChains(chainIds) {
|
|
198
|
+
return (chainIds.length > 0 &&
|
|
199
|
+
new Set(chainIds).size === chainIds.length &&
|
|
200
|
+
[MAINNETS, TESTNETS].some((family) => chainIds.every((chainId) => family.includes(chainId))));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The chains that may fund a bundle with these destinations: their network
|
|
204
|
+
* family, so a testnet action never spends mainnet ETH. Empty when the
|
|
205
|
+
* destinations are unsupported or mix families.
|
|
206
|
+
*/
|
|
207
|
+
export function relayrPaymentChains(chainIds) {
|
|
208
|
+
const unique = [...new Set(chainIds)];
|
|
209
|
+
if (!relayrSupportsChains(unique))
|
|
210
|
+
return [];
|
|
211
|
+
return [...(MAINNETS.includes(unique[0]) ? MAINNETS : TESTNETS)];
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The body to post to `${RELAYR_API}/v1/bundle/prepaid`. Each chain's
|
|
215
|
+
* transactions run in order: their virtual nonces count up from 0 per chain,
|
|
216
|
+
* independently of other chains. Throws unless every transaction is
|
|
217
|
+
* well-formed and the destinations share one network family.
|
|
218
|
+
*/
|
|
219
|
+
export function relayrBundleRequest(entries) {
|
|
220
|
+
if (!relayrSupportsChains([...new Set(entries.map((entry) => entry.chain))])) {
|
|
221
|
+
throw new Error("Choose supported destinations from one network family: mainnets or testnets.");
|
|
222
|
+
}
|
|
223
|
+
const nextNonce = new Map();
|
|
224
|
+
const transactions = entries.map((entry) => {
|
|
225
|
+
const read = readEntry(entry);
|
|
226
|
+
if (!read)
|
|
227
|
+
throw new Error("A Relayr bundle transaction is malformed.");
|
|
228
|
+
const virtual_nonce = nextNonce.get(read.chain) ?? 0;
|
|
229
|
+
nextNonce.set(read.chain, virtual_nonce + 1);
|
|
230
|
+
return {
|
|
231
|
+
chain: read.chain,
|
|
232
|
+
target: read.target,
|
|
233
|
+
data: read.data,
|
|
234
|
+
value: read.value.toString(),
|
|
235
|
+
virtual_nonce,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
return { transactions, virtual_nonce_mode: "ChainIndependent" };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Relayr's `GET /v1/bundle/{uuid}` for exactly this bundle, or `unavailable`.
|
|
242
|
+
* Never from a cache: a stale answer could say a paid bundle is unpaid.
|
|
243
|
+
*/
|
|
244
|
+
async function readBundle(fetchBundle, bundleUuid, unavailable) {
|
|
245
|
+
let bundle;
|
|
246
|
+
try {
|
|
247
|
+
const response = await fetchBundle(`${RELAYR_API}/v1/bundle/${bundleUuid}`, {
|
|
248
|
+
cache: "no-store",
|
|
249
|
+
signal: AbortSignal.timeout(BUNDLE_READ_TIMEOUT_MS),
|
|
250
|
+
});
|
|
251
|
+
if (!response.ok)
|
|
252
|
+
throw new Error(`Relayr HTTP ${response.status}`);
|
|
253
|
+
bundle = await response.json();
|
|
254
|
+
}
|
|
255
|
+
catch (cause) {
|
|
256
|
+
throw errorWithCause(unavailable, cause);
|
|
257
|
+
}
|
|
258
|
+
if (!bundle || uuidOf(bundle.bundle_uuid) !== bundleUuid) {
|
|
259
|
+
throw new Error(unavailable);
|
|
260
|
+
}
|
|
261
|
+
return bundle;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Authenticate Relayr's response to posting `request` (from
|
|
265
|
+
* {@link relayrBundleRequest}). Relayr lists transaction IDs out of request
|
|
266
|
+
* order, so each posted transaction is bound to the quoted ID whose record
|
|
267
|
+
* carries its exact request; the records are read from the bundle (with
|
|
268
|
+
* `fetch`) when the response leaves them out. The records must be exactly the
|
|
269
|
+
* quoted IDs, one each, as {@link verifyRelayrDestinations} will require of
|
|
270
|
+
* Relayr's status. Throws, with nothing paid, unless every posted transaction
|
|
271
|
+
* is bound to its own ID.
|
|
272
|
+
*/
|
|
273
|
+
export async function bindRelayrQuote(response, request, { fetch: fetchBundle = globalThis.fetch, } = {}) {
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
const detail = await response.text().catch(() => "");
|
|
276
|
+
throw new Error(`Relayr HTTP ${response.status}${detail ? `: ${detail.slice(0, HTTP_DETAIL_CHARACTERS)}` : ""}`);
|
|
277
|
+
}
|
|
278
|
+
let body;
|
|
279
|
+
try {
|
|
280
|
+
body = await response.json();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
body = null;
|
|
284
|
+
}
|
|
285
|
+
if (!body || typeof body !== "object") {
|
|
286
|
+
throw new Error("Relayr returned an unreadable quote. Nothing was paid.");
|
|
287
|
+
}
|
|
288
|
+
const bundleUuid = uuidOf(body.bundle_uuid);
|
|
289
|
+
if (!bundleUuid) {
|
|
290
|
+
throw new Error("Relayr returned no valid bundle ID. Nothing was paid.");
|
|
291
|
+
}
|
|
292
|
+
const current = Array.isArray(body.tx_uuids) ? body.tx_uuids : null;
|
|
293
|
+
const legacy = Array.isArray(body.txn_uuids) ? body.txn_uuids : null;
|
|
294
|
+
if (current && legacy && JSON.stringify(current) !== JSON.stringify(legacy)) {
|
|
295
|
+
throw new Error("Relayr returned conflicting transaction IDs. Nothing was paid.");
|
|
296
|
+
}
|
|
297
|
+
const { transactions } = request;
|
|
298
|
+
const ids = (current ?? legacy ?? []).map(uuidOf);
|
|
299
|
+
if (!transactions.length ||
|
|
300
|
+
transactions.some((entry) => !Number.isSafeInteger(entry.virtual_nonce) || entry.virtual_nonce < 0) ||
|
|
301
|
+
ids.length !== transactions.length ||
|
|
302
|
+
ids.some((id) => id === null) ||
|
|
303
|
+
new Set(ids).size !== ids.length ||
|
|
304
|
+
!Array.isArray(body.payment_info)) {
|
|
305
|
+
throw new Error(UNBOUND);
|
|
306
|
+
}
|
|
307
|
+
const quoted = new Set(ids);
|
|
308
|
+
const idOf = (record) => record && typeof record === "object"
|
|
309
|
+
? uuidOf(record.tx_uuid)
|
|
310
|
+
: null;
|
|
311
|
+
let records = Array.isArray(body.transactions)
|
|
312
|
+
? body.transactions
|
|
313
|
+
: [];
|
|
314
|
+
if (records.length !== transactions.length ||
|
|
315
|
+
records.some((record) => idOf(record) === null || !record.request)) {
|
|
316
|
+
const bundle = await readBundle(fetchBundle, bundleUuid, UNRETURNED);
|
|
317
|
+
if (!Array.isArray(bundle.transactions))
|
|
318
|
+
throw new Error(UNRETURNED);
|
|
319
|
+
records = bundle.transactions;
|
|
320
|
+
}
|
|
321
|
+
// Every record carries one of the quoted IDs, once. With every posted
|
|
322
|
+
// transaction bound to its own record below, the records are then exactly
|
|
323
|
+
// the quoted IDs, as the destination proof requires of Relayr's status.
|
|
324
|
+
const recordIds = records.map(idOf);
|
|
325
|
+
if (recordIds.some((id) => id === null || !quoted.has(id)) ||
|
|
326
|
+
new Set(recordIds).size !== records.length) {
|
|
327
|
+
throw new Error(UNBOUND);
|
|
328
|
+
}
|
|
329
|
+
const bound = transactions.map((entry) => {
|
|
330
|
+
const matches = records.filter((record) => isRequestFor(record.request, entry, "required"));
|
|
331
|
+
if (matches.length !== 1)
|
|
332
|
+
throw new Error(UNBOUND);
|
|
333
|
+
return matches[0];
|
|
334
|
+
});
|
|
335
|
+
const expectedTransactions = bound.map((record, index) => ({
|
|
336
|
+
txUuid: idOf(record),
|
|
337
|
+
chain: transactions[index].chain,
|
|
338
|
+
entry: { ...transactions[index] },
|
|
339
|
+
}));
|
|
340
|
+
if (new Set(expectedTransactions.map((binding) => binding.txUuid)).size !==
|
|
341
|
+
transactions.length) {
|
|
342
|
+
throw new Error(UNBOUND);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
bundle_uuid: bundleUuid,
|
|
346
|
+
payment_info: body.payment_info,
|
|
347
|
+
transactions: bound,
|
|
348
|
+
expectedTransactions,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** A quoted deadline in seconds: integer seconds, or an RFC 3339 time with an offset. */
|
|
352
|
+
function deadlineSeconds(value) {
|
|
353
|
+
if (typeof value === "number") {
|
|
354
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
355
|
+
}
|
|
356
|
+
if (typeof value !== "string")
|
|
357
|
+
return null;
|
|
358
|
+
if (/^\d+$/u.test(value)) {
|
|
359
|
+
const seconds = Number(value);
|
|
360
|
+
return Number.isSafeInteger(seconds) ? seconds : null;
|
|
361
|
+
}
|
|
362
|
+
const milliseconds = DEADLINE_TIME.test(value) ? Date.parse(value) : NaN;
|
|
363
|
+
return Number.isFinite(milliseconds) && milliseconds >= 0
|
|
364
|
+
? Math.floor(milliseconds / 1_000)
|
|
365
|
+
: null;
|
|
366
|
+
}
|
|
367
|
+
/** The deadline word of payment calldata. */
|
|
368
|
+
function calldataDeadline(calldata) {
|
|
369
|
+
return BigInt(`0x${calldata.slice(74)}`);
|
|
370
|
+
}
|
|
371
|
+
/** A quote is dead once its deadline is 15 seconds away or less. */
|
|
372
|
+
function quoteExpired(deadline, nowSeconds) {
|
|
373
|
+
return deadline <= BigInt(Math.floor(nowSeconds)) + 15n;
|
|
374
|
+
}
|
|
375
|
+
/** Payment calldata: the selector, the bundle UUID as a right-padded bytes16 word, and a uint40 deadline word. */
|
|
376
|
+
function paymentCalldataFor(calldata, bundleUuid) {
|
|
377
|
+
return (typeof calldata === "string" &&
|
|
378
|
+
/^0x[0-9a-f]{136}$/iu.test(calldata) &&
|
|
379
|
+
calldata.slice(0, 10).toLowerCase() === RELAYR_PAYMENT_SELECTOR &&
|
|
380
|
+
calldata.slice(10, 74).toLowerCase() ===
|
|
381
|
+
`${bundleUuid.replaceAll("-", "")}${"0".repeat(32)}` &&
|
|
382
|
+
calldataDeadline(calldata) <= 0xffffffffffn);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Authenticate one of Relayr's payment options against its bundle: a chain in
|
|
386
|
+
* the destinations' network family, Relayr's payment contract and native
|
|
387
|
+
* token, calldata that pays for exactly `bundleUuid` with the quoted
|
|
388
|
+
* deadline, and a deadline more than 15 seconds away. Throws otherwise.
|
|
389
|
+
*/
|
|
390
|
+
export function relayrPaymentDetails(payment, { bundleUuid, destinationChainIds, nowSeconds = Date.now() / 1_000, }) {
|
|
391
|
+
const chainId = payment?.chain;
|
|
392
|
+
if (typeof chainId !== "number" || !relayrSupportsChain(chainId)) {
|
|
393
|
+
throw new Error("Relayr returned an unsupported payment chain.");
|
|
394
|
+
}
|
|
395
|
+
if (!relayrPaymentChains(destinationChainIds).includes(chainId)) {
|
|
396
|
+
throw new Error("Choose a supported Relayr funding chain in the same network family as these destinations.");
|
|
397
|
+
}
|
|
398
|
+
if (!sameAddress(payment.target, RELAYR_PAYMENT_ADDRESS)) {
|
|
399
|
+
throw new Error("Relayr returned an unrecognized payment contract.");
|
|
400
|
+
}
|
|
401
|
+
if (!sameAddress(payment.token, RELAYR_NATIVE_TOKEN)) {
|
|
402
|
+
throw new Error("Relayr returned an unsupported payment token.");
|
|
403
|
+
}
|
|
404
|
+
const amount = uint256(payment.amount);
|
|
405
|
+
if (amount === null) {
|
|
406
|
+
throw new Error("Relayr returned an invalid payment amount.");
|
|
407
|
+
}
|
|
408
|
+
const uuid = uuidOf(bundleUuid);
|
|
409
|
+
if (!uuid)
|
|
410
|
+
throw new Error("Relayr returned an invalid bundle ID.");
|
|
411
|
+
const calldata = typeof payment.calldata === "string" ? payment.calldata.toLowerCase() : "";
|
|
412
|
+
if (!/^0x[0-9a-f]{136}$/u.test(calldata)) {
|
|
413
|
+
throw new Error("Relayr returned invalid payment calldata.");
|
|
414
|
+
}
|
|
415
|
+
if (calldata.slice(0, 10) !== RELAYR_PAYMENT_SELECTOR) {
|
|
416
|
+
throw new Error("Relayr returned an unrecognized payment function.");
|
|
417
|
+
}
|
|
418
|
+
if (calldata.slice(10, 74) !== `${uuid.replaceAll("-", "")}${"0".repeat(32)}`) {
|
|
419
|
+
throw new Error("Relayr payment calldata does not match this bundle.");
|
|
420
|
+
}
|
|
421
|
+
const deadline = calldataDeadline(calldata);
|
|
422
|
+
if (deadline > 0xffffffffffn) {
|
|
423
|
+
throw new Error("Relayr returned an invalid payment deadline.");
|
|
424
|
+
}
|
|
425
|
+
const quoted = deadlineSeconds(payment.payment_deadline);
|
|
426
|
+
if (quoted === null || BigInt(quoted) !== deadline) {
|
|
427
|
+
throw new Error("Relayr payment calldata does not match the quote deadline.");
|
|
428
|
+
}
|
|
429
|
+
if (quoteExpired(deadline, nowSeconds))
|
|
430
|
+
throw new Error(EXPIRED);
|
|
431
|
+
return {
|
|
432
|
+
chainId,
|
|
433
|
+
target: RELAYR_PAYMENT_ADDRESS,
|
|
434
|
+
amount,
|
|
435
|
+
calldata: calldata,
|
|
436
|
+
bundleUuid: uuid,
|
|
437
|
+
deadline,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The payment options to offer: each passes {@link relayrPaymentDetails} for
|
|
442
|
+
* the quote's bundle and destinations, one per chain (Relayr's first), copied
|
|
443
|
+
* so the quote's owner cannot change one under a later review.
|
|
444
|
+
*/
|
|
445
|
+
export function relayrPaymentOptions(quote, destinationChainIds, nowSeconds) {
|
|
446
|
+
const chains = new Set();
|
|
447
|
+
const options = [];
|
|
448
|
+
for (const payment of Array.isArray(quote.payment_info)
|
|
449
|
+
? quote.payment_info
|
|
450
|
+
: []) {
|
|
451
|
+
let chainId;
|
|
452
|
+
try {
|
|
453
|
+
({ chainId } = relayrPaymentDetails(payment, {
|
|
454
|
+
bundleUuid: quote.bundle_uuid,
|
|
455
|
+
destinationChainIds,
|
|
456
|
+
nowSeconds,
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (chains.has(chainId))
|
|
463
|
+
continue;
|
|
464
|
+
chains.add(chainId);
|
|
465
|
+
options.push({ ...payment });
|
|
466
|
+
}
|
|
467
|
+
return options;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Prove the payment chain runs Relayr's payment contract: the code at
|
|
471
|
+
* {@link RELAYR_PAYMENT_ADDRESS} hashes to {@link RELAYR_PAYMENT_CODE_HASH}.
|
|
472
|
+
*/
|
|
473
|
+
export async function requireRelayrPaymentRuntime(client) {
|
|
474
|
+
let code;
|
|
475
|
+
try {
|
|
476
|
+
code = await client.getCode({
|
|
477
|
+
address: RELAYR_PAYMENT_ADDRESS,
|
|
478
|
+
blockTag: "latest",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (cause) {
|
|
482
|
+
throw errorWithCause("Could not authenticate the Relayr payment contract.", cause);
|
|
483
|
+
}
|
|
484
|
+
if (typeof code !== "string" ||
|
|
485
|
+
!/^0x(?:[0-9a-f]{2})+$/iu.test(code) ||
|
|
486
|
+
(code.length - 2) / 2 > PAYMENT_CODE_MAX_BYTES) {
|
|
487
|
+
throw new Error("Could not authenticate the Relayr payment contract.");
|
|
488
|
+
}
|
|
489
|
+
if (keccak256(code) !== RELAYR_PAYMENT_CODE_HASH) {
|
|
490
|
+
throw new Error("Relayr payment contract code is not recognized.");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The node's own words for a failure: a viem error's `details` in its first
|
|
495
|
+
* eight causes. Its `message` is never read, since over HTTP it quotes the
|
|
496
|
+
* RPC URL. Only an error from outside viem is read by its message.
|
|
497
|
+
*/
|
|
498
|
+
function nodeDetail(error) {
|
|
499
|
+
let current = error;
|
|
500
|
+
for (let depth = 0; depth < 8 && current && typeof current === "object"; depth += 1) {
|
|
501
|
+
const { details, cause } = current;
|
|
502
|
+
if (typeof details === "string" && details.trim()) {
|
|
503
|
+
return details.trim().split("\n")[0].slice(0, HTTP_DETAIL_CHARACTERS);
|
|
504
|
+
}
|
|
505
|
+
current = cause;
|
|
506
|
+
}
|
|
507
|
+
return error instanceof Error && !("shortMessage" in error)
|
|
508
|
+
? error.message.split("\n")[0].slice(0, HTTP_DETAIL_CHARACTERS)
|
|
509
|
+
: "the node refused the call";
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Simulate the exact payment from `from` with the gas it is sent with,
|
|
513
|
+
* without CCIP-read. Relayr's contract returns nothing, so a revert or any
|
|
514
|
+
* return data refuses the payment.
|
|
515
|
+
*/
|
|
516
|
+
export async function simulateRelayrPayment(client, { from, payment, }) {
|
|
517
|
+
let result;
|
|
518
|
+
try {
|
|
519
|
+
result = await simulateStateChangingTransaction(client, {
|
|
520
|
+
from,
|
|
521
|
+
to: payment.target,
|
|
522
|
+
data: payment.calldata,
|
|
523
|
+
value: payment.amount,
|
|
524
|
+
gas: RELAYR_PAYMENT_GAS,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (cause) {
|
|
528
|
+
throw errorWithCause(`The Relayr payment would fail: ${nodeDetail(cause)}`, cause);
|
|
529
|
+
}
|
|
530
|
+
if (result !== "0x") {
|
|
531
|
+
throw new Error("Relayr payment simulation returned an unexpected result.");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** A status label as Relayr's states are compared: trimmed, in any case. */
|
|
535
|
+
function stateLabel(state) {
|
|
536
|
+
return typeof state === "string" ? state.trim().toLowerCase() : "";
|
|
537
|
+
}
|
|
538
|
+
/** Success is `success` or `completed`, in any case. */
|
|
539
|
+
export function relayrStateIsSuccess(state) {
|
|
540
|
+
const label = stateLabel(state);
|
|
541
|
+
return label === "success" || label === "completed";
|
|
542
|
+
}
|
|
543
|
+
/** `pending`, in any case: Relayr has not run the transaction. */
|
|
544
|
+
function relayrStateIsPending(state) {
|
|
545
|
+
return stateLabel(state) === "pending";
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Only `failed`, in any case, is a failure. Relayr's other states say nothing
|
|
549
|
+
* final; receipts stay the proof either way.
|
|
550
|
+
*/
|
|
551
|
+
export function relayrStateIsFailed(state) {
|
|
552
|
+
return stateLabel(state) === "failed";
|
|
553
|
+
}
|
|
554
|
+
/** Counts by Relayr's labels, with rows it has not reported yet counted as pending. */
|
|
555
|
+
export function relayrProgress(records, expectedCount = records.length) {
|
|
556
|
+
const total = Math.max(expectedCount, records.length);
|
|
557
|
+
const confirmed = records.filter((record) => relayrStateIsSuccess(record?.status?.state)).length;
|
|
558
|
+
const failed = records.filter((record) => relayrStateIsFailed(record?.status?.state)).length;
|
|
559
|
+
return {
|
|
560
|
+
confirmed,
|
|
561
|
+
failed,
|
|
562
|
+
pending: Math.max(total - confirmed - failed, 0),
|
|
563
|
+
total,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/** The destination transaction hash a record reports, if it is a transaction hash. */
|
|
567
|
+
export function relayrDestinationHash(record) {
|
|
568
|
+
const data = record?.status?.data;
|
|
569
|
+
const hash = data?.hash ?? data?.transaction?.hash;
|
|
570
|
+
return typeof hash === "string" && TRANSACTION_HASH.test(hash) ? hash : null;
|
|
571
|
+
}
|
|
572
|
+
/** A record's destination chain. Relayr's status nests it under `request`. */
|
|
573
|
+
export function relayrRecordChain(record) {
|
|
574
|
+
const chain = record?.request?.chain ?? record?.chain;
|
|
575
|
+
return typeof chain === "number" && Number.isSafeInteger(chain) && chain > 0
|
|
576
|
+
? chain
|
|
577
|
+
: null;
|
|
578
|
+
}
|
|
579
|
+
function forwarderOn(chainId) {
|
|
580
|
+
return relayrSupportsChain(chainId)
|
|
581
|
+
? jbContractAddress["6"].ERC2771Forwarder[chainId]
|
|
582
|
+
: undefined;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* The ERC-2771 request an entry carries: an `execute` call on its chain's
|
|
586
|
+
* canonical Juicebox V6 forwarder whose calldata re-encodes byte for byte.
|
|
587
|
+
* Null for anything else: a raw call, another forwarder, another function or
|
|
588
|
+
* a dirty encoding.
|
|
589
|
+
*/
|
|
590
|
+
export function relayrForwardRequest(entry) {
|
|
591
|
+
const forwarder = forwarderOn(entry?.chain);
|
|
592
|
+
if (!forwarder ||
|
|
593
|
+
!sameAddress(entry.target, forwarder) ||
|
|
594
|
+
!isHexBytes(entry.data)) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
const decoded = decodeFunctionData({
|
|
599
|
+
abi: erc2771ForwarderAbi,
|
|
600
|
+
data: entry.data,
|
|
601
|
+
});
|
|
602
|
+
if (decoded.functionName !== "execute")
|
|
603
|
+
return null;
|
|
604
|
+
const [request] = decoded.args;
|
|
605
|
+
const encoded = encodeFunctionData({
|
|
606
|
+
abi: erc2771ForwarderAbi,
|
|
607
|
+
functionName: "execute",
|
|
608
|
+
args: [request],
|
|
609
|
+
});
|
|
610
|
+
return encoded.toLowerCase() === entry.data.toLowerCase()
|
|
611
|
+
? { ...request }
|
|
612
|
+
: null;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Read `expected.hash` and prove it is exactly the expected transaction,
|
|
620
|
+
* canonically included. Returns its receipt, successful or reverted. A
|
|
621
|
+
* transaction hash commits to its sender, target, calldata, value and chain,
|
|
622
|
+
* so any difference there is a {@link RelayrProofError}. A receipt,
|
|
623
|
+
* transaction and block that disagree (a lagging node, a reorg) prove nothing
|
|
624
|
+
* either way.
|
|
625
|
+
*/
|
|
626
|
+
async function proveTransaction(client, expected, words) {
|
|
627
|
+
const hash = expected.hash.toLowerCase();
|
|
628
|
+
let transaction;
|
|
629
|
+
let receipt;
|
|
630
|
+
try {
|
|
631
|
+
[transaction, receipt] = await Promise.all([
|
|
632
|
+
client.getTransaction({ hash: expected.hash }),
|
|
633
|
+
client.getTransactionReceipt({ hash: expected.hash }),
|
|
634
|
+
]);
|
|
635
|
+
}
|
|
636
|
+
catch (cause) {
|
|
637
|
+
throw errorWithCause(words.unavailable, cause);
|
|
638
|
+
}
|
|
639
|
+
if (transaction?.hash?.toLowerCase() !== hash ||
|
|
640
|
+
receipt?.transactionHash?.toLowerCase() !== hash) {
|
|
641
|
+
throw new Error(words.unavailable);
|
|
642
|
+
}
|
|
643
|
+
if (transaction.chainId !== expected.chainId ||
|
|
644
|
+
!sameAddress(transaction.to, expected.to) ||
|
|
645
|
+
typeof transaction.input !== "string" ||
|
|
646
|
+
transaction.input.toLowerCase() !== expected.data.toLowerCase() ||
|
|
647
|
+
transaction.value !== expected.value ||
|
|
648
|
+
(expected.from !== undefined &&
|
|
649
|
+
!sameAddress(transaction.from, expected.from))) {
|
|
650
|
+
throw new RelayrProofError(words.mismatch);
|
|
651
|
+
}
|
|
652
|
+
if (!sameAddress(receipt.to, expected.to) ||
|
|
653
|
+
typeof receipt.blockHash !== "string" ||
|
|
654
|
+
!TRANSACTION_HASH.test(receipt.blockHash) ||
|
|
655
|
+
transaction.blockHash?.toLowerCase() !== receipt.blockHash.toLowerCase() ||
|
|
656
|
+
typeof receipt.blockNumber !== "bigint" ||
|
|
657
|
+
transaction.blockNumber !== receipt.blockNumber ||
|
|
658
|
+
(receipt.status !== "success" && receipt.status !== "reverted")) {
|
|
659
|
+
throw new Error(words.unavailable);
|
|
660
|
+
}
|
|
661
|
+
let block;
|
|
662
|
+
try {
|
|
663
|
+
block = await client.getBlock({ blockNumber: receipt.blockNumber });
|
|
664
|
+
}
|
|
665
|
+
catch (cause) {
|
|
666
|
+
throw errorWithCause(words.unavailable, cause);
|
|
667
|
+
}
|
|
668
|
+
if (block?.hash?.toLowerCase() !== receipt.blockHash.toLowerCase()) {
|
|
669
|
+
throw new Error(words.notCanonical);
|
|
670
|
+
}
|
|
671
|
+
return receipt;
|
|
672
|
+
}
|
|
673
|
+
/** A reviewed payment's parts, or null unless it is an authenticated Relayr payment. */
|
|
674
|
+
function readReviewedPayment(payment) {
|
|
675
|
+
const bundleUuid = uuidOf(payment?.bundleUuid);
|
|
676
|
+
const amount = uint256(payment?.amount);
|
|
677
|
+
if (!relayrSupportsChain(payment?.chainId) ||
|
|
678
|
+
!sameAddress(payment.target, RELAYR_PAYMENT_ADDRESS) ||
|
|
679
|
+
amount === null ||
|
|
680
|
+
bundleUuid === null ||
|
|
681
|
+
!paymentCalldataFor(payment.calldata, bundleUuid)) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
return {
|
|
685
|
+
chainId: payment.chainId,
|
|
686
|
+
amount,
|
|
687
|
+
bundleUuid,
|
|
688
|
+
calldata: payment.calldata,
|
|
689
|
+
deadline: calldataDeadline(payment.calldata),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Prove a Relayr payment from the chain: the transaction at `hash` is exactly
|
|
694
|
+
* `payment` sent by `from` (chain, payment contract, calldata, value),
|
|
695
|
+
* canonically included, and successful. Pass the hash that was mined: when a
|
|
696
|
+
* wallet speeds a payment up, viem's receipt is the replacement's, so verify
|
|
697
|
+
* its `transactionHash`, not the hash the wallet first returned.
|
|
698
|
+
*
|
|
699
|
+
* Throws {@link RelayrPaymentRevertedError} when it canonically reverted. That
|
|
700
|
+
* transaction paid nothing, but the bundle may still be paid by another, so
|
|
701
|
+
* only {@link requireRelayrPaymentRetry} clears the quote to be paid again.
|
|
702
|
+
* {@link RelayrProofError} means the transaction is a different one: never pay
|
|
703
|
+
* again. Any other error means the proof is unavailable for now.
|
|
704
|
+
*/
|
|
705
|
+
export async function verifyRelayrPayment(client, { hash, from, payment, }) {
|
|
706
|
+
const reviewed = readReviewedPayment(payment);
|
|
707
|
+
if (typeof hash !== "string" ||
|
|
708
|
+
!TRANSACTION_HASH.test(hash) ||
|
|
709
|
+
!isAddressLike(from) ||
|
|
710
|
+
!reviewed) {
|
|
711
|
+
throw new Error("Only an authenticated Relayr payment with its transaction hash can be verified.");
|
|
712
|
+
}
|
|
713
|
+
const receipt = await proveTransaction(client, {
|
|
714
|
+
hash,
|
|
715
|
+
chainId: reviewed.chainId,
|
|
716
|
+
from,
|
|
717
|
+
to: RELAYR_PAYMENT_ADDRESS,
|
|
718
|
+
data: reviewed.calldata,
|
|
719
|
+
value: reviewed.amount,
|
|
720
|
+
}, {
|
|
721
|
+
unavailable: `Could not read Relayr payment ${hash} on chain ${reviewed.chainId}. Do not pay again; check it later.`,
|
|
722
|
+
mismatch: "The funding transaction does not match the reviewed Relayr payment. Do not pay again; inspect the wallet's activity and the saved bundle.",
|
|
723
|
+
notCanonical: "The Relayr funding receipt is no longer canonical. Do not pay again; check it later.",
|
|
724
|
+
});
|
|
725
|
+
if (receipt.status === "reverted") {
|
|
726
|
+
throw new RelayrPaymentRevertedError("The Relayr funding transaction reverted onchain.", hash, reviewed.chainId);
|
|
727
|
+
}
|
|
728
|
+
return receipt;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Clear a saved quote to be paid once more after its payments reverted.
|
|
732
|
+
* `hashes` is every payment this session sent for the quote, each as mined
|
|
733
|
+
* (see {@link verifyRelayrPayment}). A bundle ID belongs to one quote, and a
|
|
734
|
+
* quote to the session on the device that requested it, so the session's own
|
|
735
|
+
* hashes are every payment that could have funded the bundle. Resolves only
|
|
736
|
+
* when:
|
|
737
|
+
*
|
|
738
|
+
* - every one of them is exactly the reviewed payment, canonically reverted;
|
|
739
|
+
* - the quote's deadline is more than 15 seconds away (`nowSeconds`, by
|
|
740
|
+
* default the clock);
|
|
741
|
+
* - Relayr's bundle, read with `fetch` and never from a cache, reports
|
|
742
|
+
* `payment_received: false`, and every record is still pending with no
|
|
743
|
+
* destination hash. Relayr runs only paid bundles.
|
|
744
|
+
*
|
|
745
|
+
* The payment contract keeps no state and Relayr keeps every payment it
|
|
746
|
+
* receives, so anything else rules a new payment out, and an empty list never
|
|
747
|
+
* clears one. A payment still in flight from elsewhere is not visible yet.
|
|
748
|
+
* Throws in every other case, including when an answer is unavailable. Never
|
|
749
|
+
* pay on an error.
|
|
750
|
+
*/
|
|
751
|
+
export async function requireRelayrPaymentRetry(client, { hashes, from, payment, }, { fetch: fetchBundle = globalThis.fetch, nowSeconds = Date.now() / 1_000, } = {}) {
|
|
752
|
+
if (!Array.isArray(hashes) || !hashes.length) {
|
|
753
|
+
throw new Error("Name every payment this session sent for the quote before paying it again.");
|
|
754
|
+
}
|
|
755
|
+
const reviewed = readReviewedPayment(payment);
|
|
756
|
+
if (reviewed && quoteExpired(reviewed.deadline, nowSeconds)) {
|
|
757
|
+
throw new Error(EXPIRED);
|
|
758
|
+
}
|
|
759
|
+
for (const hash of hashes) {
|
|
760
|
+
try {
|
|
761
|
+
await verifyRelayrPayment(client, { hash, from, payment });
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
if (error instanceof RelayrPaymentRevertedError)
|
|
765
|
+
continue;
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
throw new Error("This Relayr payment succeeded onchain, so its bundle is paid. Do not pay again.");
|
|
769
|
+
}
|
|
770
|
+
const { payment_received, transactions } = await readBundle(fetchBundle, reviewed.bundleUuid, UNKNOWN_PAYMENT);
|
|
771
|
+
if (payment_received === true) {
|
|
772
|
+
throw new Error("Relayr already reports a payment for this bundle. Do not pay again.");
|
|
773
|
+
}
|
|
774
|
+
if (payment_received !== false ||
|
|
775
|
+
!Array.isArray(transactions) ||
|
|
776
|
+
!transactions.length) {
|
|
777
|
+
throw new Error(UNKNOWN_PAYMENT);
|
|
778
|
+
}
|
|
779
|
+
if (transactions.some((record) => relayrDestinationHash(record) !== null ||
|
|
780
|
+
!relayrStateIsPending(record?.status?.state))) {
|
|
781
|
+
throw new Error("Relayr reports a transaction of this bundle as running or run. Do not pay again.");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function destinationWords(hash, chainId) {
|
|
785
|
+
return {
|
|
786
|
+
unavailable: `Could not read destination transaction ${hash} on chain ${chainId}. Keep the original bundle pending; do not pay again.`,
|
|
787
|
+
mismatch: "The destination transaction does not prove the signed Relayr call. Keep the original bundle pending; do not pay again.",
|
|
788
|
+
notCanonical: "The destination receipt is no longer canonical. Keep the original bundle pending.",
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Prove one destination from the chain: the transaction at `hash` is exactly
|
|
793
|
+
* `entry` (chain, target, calldata, value), canonically included, and
|
|
794
|
+
* successful. Throws {@link RelayrDestinationRevertedError} when it
|
|
795
|
+
* canonically reverted, {@link RelayrProofError} when it is a different
|
|
796
|
+
* transaction, and any other error while the proof is unavailable.
|
|
797
|
+
*/
|
|
798
|
+
export async function verifyRelayrDestination(client, { entry, hash }) {
|
|
799
|
+
const read = readEntry(entry);
|
|
800
|
+
if (!read || typeof hash !== "string" || !TRANSACTION_HASH.test(hash)) {
|
|
801
|
+
throw new Error(NO_PROOF);
|
|
802
|
+
}
|
|
803
|
+
const receipt = await proveTransaction(client, {
|
|
804
|
+
hash,
|
|
805
|
+
chainId: read.chain,
|
|
806
|
+
to: read.target,
|
|
807
|
+
data: read.data,
|
|
808
|
+
value: read.value,
|
|
809
|
+
}, destinationWords(hash, read.chain));
|
|
810
|
+
if (receipt.status === "reverted") {
|
|
811
|
+
throw new RelayrDestinationRevertedError("The destination receipt does not prove the signed Relayr call succeeded: it reverted onchain. Keep the original bundle pending; do not pay again.", hash, read.chain);
|
|
812
|
+
}
|
|
813
|
+
return receipt;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Prove every destination of a bundle from the chain. `bindings` are the
|
|
817
|
+
* quote's {@link RelayrQuote.expectedTransactions}; `records` are Relayr's
|
|
818
|
+
* status records, which only say which hash to check. Every record must carry
|
|
819
|
+
* one of the quote's IDs, once. Each binding then needs exactly one record on
|
|
820
|
+
* its chain carrying its exact request; only a record without a request is
|
|
821
|
+
* paired by its ID, since bindings saved before request binding paired IDs by
|
|
822
|
+
* position. Every hash must prove its binding onchain with
|
|
823
|
+
* {@link verifyRelayrDestination}.
|
|
824
|
+
*
|
|
825
|
+
* With `account`, every entry must also be that account's ERC-2771 request
|
|
826
|
+
* on its chain's canonical forwarder, for the entry's own value.
|
|
827
|
+
*
|
|
828
|
+
* Returns the receipts in binding order. Throws
|
|
829
|
+
* {@link RelayrDestinationRevertedError} when a destination canonically
|
|
830
|
+
* reverted, another {@link RelayrProofError} when Relayr or the chain
|
|
831
|
+
* contradicts a binding, and any other error while proof is unavailable.
|
|
832
|
+
* Never pay again on any error.
|
|
833
|
+
*/
|
|
834
|
+
export async function verifyRelayrDestinations(clientFor, { bindings, records, account, }) {
|
|
835
|
+
const ids = bindings.map((binding) => uuidOf(binding?.txUuid));
|
|
836
|
+
if (!bindings.length ||
|
|
837
|
+
ids.some((id) => id === null) ||
|
|
838
|
+
new Set(ids).size !== bindings.length ||
|
|
839
|
+
bindings.some((binding) => readEntry(binding.entry)?.chain !== binding.chain)) {
|
|
840
|
+
throw new Error(NO_PROOF);
|
|
841
|
+
}
|
|
842
|
+
if (account !== undefined) {
|
|
843
|
+
for (const { entry } of bindings) {
|
|
844
|
+
const request = relayrForwardRequest(entry);
|
|
845
|
+
if (!isAddressLike(account) ||
|
|
846
|
+
!request ||
|
|
847
|
+
!sameAddress(request.from, account) ||
|
|
848
|
+
request.value !== uint256(entry.value)) {
|
|
849
|
+
throw new Error("The saved relay authorization does not match its account or value.");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const quoted = new Set(ids);
|
|
854
|
+
const recordIds = records.map((record) => uuidOf(record?.tx_uuid));
|
|
855
|
+
if (records.length !== bindings.length)
|
|
856
|
+
throw new Error(NOT_IDENTIFIED);
|
|
857
|
+
if (recordIds.some((id) => id === null || !quoted.has(id)) ||
|
|
858
|
+
new Set(recordIds).size !== records.length) {
|
|
859
|
+
throw new RelayrProofError("Relayr's status names a transaction this quote did not bind. Keep the original bundle pending; do not pay again.");
|
|
860
|
+
}
|
|
861
|
+
const hashes = bindings.map((binding, index) => {
|
|
862
|
+
const matches = records.filter((record, recordIndex) => relayrRecordChain(record) === binding.chain &&
|
|
863
|
+
(record.request !== undefined && record.request !== null
|
|
864
|
+
? isRequestFor(record.request, binding.entry, "when-present")
|
|
865
|
+
: recordIds[recordIndex] === ids[index]));
|
|
866
|
+
if (matches.length !== 1) {
|
|
867
|
+
throw new RelayrProofError("Relayr's destination call does not match the signed request. Keep the original bundle pending; do not pay again.");
|
|
868
|
+
}
|
|
869
|
+
const hash = relayrDestinationHash(matches[0]);
|
|
870
|
+
if (!hash)
|
|
871
|
+
throw new Error(NOT_IDENTIFIED);
|
|
872
|
+
return hash;
|
|
873
|
+
});
|
|
874
|
+
if (new Set(hashes.map((hash) => hash.toLowerCase())).size !== hashes.length) {
|
|
875
|
+
throw new RelayrProofError("Relayr reported one destination transaction for two signed calls. Keep the original bundle pending; do not pay again.");
|
|
876
|
+
}
|
|
877
|
+
const verified = [];
|
|
878
|
+
for (const [index, binding] of bindings.entries()) {
|
|
879
|
+
const client = clientFor(binding.chain);
|
|
880
|
+
if (!client) {
|
|
881
|
+
throw new Error(`No RPC is available for chain ${binding.chain}. Keep the original bundle pending; do not pay again.`);
|
|
882
|
+
}
|
|
883
|
+
verified.push({
|
|
884
|
+
txUuid: ids[index],
|
|
885
|
+
chainId: binding.chain,
|
|
886
|
+
receipt: await verifyRelayrDestination(client, {
|
|
887
|
+
entry: binding.entry,
|
|
888
|
+
hash: hashes[index],
|
|
889
|
+
}),
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return verified;
|
|
893
|
+
}
|
|
894
|
+
//# sourceMappingURL=relayr.js.map
|