@bananapus/nana-sdk-core 2.21.0 → 2.23.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/relayr.d.ts +329 -0
- package/dist/cjs/review/relayr.d.ts.map +1 -1
- package/dist/cjs/review/relayr.js +610 -11
- package/dist/cjs/review/relayr.js.map +1 -1
- package/dist/esm/review/relayr.d.ts +329 -0
- package/dist/esm/review/relayr.d.ts.map +1 -1
- package/dist/esm/review/relayr.js +593 -11
- package/dist/esm/review/relayr.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { decodeFunctionData, encodeFunctionData, isAddress, keccak256, } from "viem";
|
|
1
|
+
import { ContractFunctionRevertedError, decodeFunctionData, encodeFunctionData, isAddress, keccak256, RpcError, RpcRequestError, } from "viem";
|
|
2
2
|
import { erc2771ForwarderAbi, jbContractAddress, } from "../generated/juicebox.js";
|
|
3
3
|
import { isBytes32, isHexBytes, uint256 } from "../untrusted.js";
|
|
4
|
+
import { isDefiniteWalletRejection } from "./contractWrite.js";
|
|
4
5
|
import { simulateStateChangingTransaction } from "./simulation.js";
|
|
5
6
|
// Relayr runs a bundle of transactions on several chains for one prepaid
|
|
6
7
|
// native payment on one of them. Everything its API returns is untrusted:
|
|
@@ -59,7 +60,8 @@ const TESTNETS = [11155111, 11155420, 84532, 421614];
|
|
|
59
60
|
const PAYMENT_CODE_MAX_BYTES = 2_048;
|
|
60
61
|
const BUNDLE_READ_TIMEOUT_MS = 15_000;
|
|
61
62
|
const HTTP_DETAIL_CHARACTERS = 240;
|
|
62
|
-
|
|
63
|
+
/** A Relayr bundle or transaction ID, in lower case. */
|
|
64
|
+
export const RELAYR_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
63
65
|
// RFC 3339 with an explicit offset. A time without one is read in the
|
|
64
66
|
// machine's timezone, which would make the same quote pass or fail by place.
|
|
65
67
|
const DEADLINE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/iu;
|
|
@@ -69,6 +71,7 @@ const NO_PROOF = "This saved Relayr bundle lacks exact destination proof. Keep i
|
|
|
69
71
|
const NOT_IDENTIFIED = "Relayr has not identified every exact destination transaction. Keep checking the original bundle; do not pay again.";
|
|
70
72
|
const EXPIRED = "This Relayr quote expired. Review the action again for a new quote.";
|
|
71
73
|
const UNKNOWN_PAYMENT = "Relayr has not said whether this bundle is paid. Do not pay again yet; check it later.";
|
|
74
|
+
const UNNAMED = "Name every payment this session sent for the quote before paying it again.";
|
|
72
75
|
const UNVERIFIABLE = "Only an authenticated Relayr payment with its transaction hash can be verified.";
|
|
73
76
|
/**
|
|
74
77
|
* The chain or Relayr contradicts the expected transaction: this evidence can
|
|
@@ -121,15 +124,19 @@ export class RelayrPaymentRetryError extends Error {
|
|
|
121
124
|
this.name = "RelayrPaymentRetryError";
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
|
-
/** `
|
|
125
|
-
function
|
|
126
|
-
Object.defineProperty(
|
|
127
|
-
value
|
|
127
|
+
/** `target` with `key` set to `value`, writable but never enumerable: an RPC error's URL can carry a key, and no JSON or log may show it. */
|
|
128
|
+
function withHidden(target, key, value) {
|
|
129
|
+
Object.defineProperty(target, key, {
|
|
130
|
+
value,
|
|
128
131
|
writable: true,
|
|
129
132
|
enumerable: false,
|
|
130
133
|
configurable: true,
|
|
131
134
|
});
|
|
132
|
-
return
|
|
135
|
+
return target;
|
|
136
|
+
}
|
|
137
|
+
/** `error` with `cause` set like a native error cause, but on ES2021 and never enumerable: an RPC error's URL can carry a key. */
|
|
138
|
+
function withCause(error, cause) {
|
|
139
|
+
return withHidden(error, "cause", cause);
|
|
133
140
|
}
|
|
134
141
|
function errorWithCause(message, cause) {
|
|
135
142
|
return withCause(new Error(message), cause);
|
|
@@ -155,7 +162,7 @@ function uuidOf(value) {
|
|
|
155
162
|
if (typeof value !== "string")
|
|
156
163
|
return null;
|
|
157
164
|
const uuid = value.toLowerCase();
|
|
158
|
-
return
|
|
165
|
+
return RELAYR_UUID_RE.test(uuid) ? uuid : null;
|
|
159
166
|
}
|
|
160
167
|
function readEntry(entry) {
|
|
161
168
|
if (!entry || typeof entry !== "object")
|
|
@@ -792,7 +799,7 @@ export async function verifyRelayrPayment(client, { hash, from, payment, }) {
|
|
|
792
799
|
*/
|
|
793
800
|
export async function requireRelayrPaymentRetry(client, { hashes, from, payment, }, { fetch: fetchBundle = globalThis.fetch, nowSeconds = Date.now() / 1_000, } = {}) {
|
|
794
801
|
if (!Array.isArray(hashes) || !hashes.length) {
|
|
795
|
-
throw new RelayrPaymentRetryError(
|
|
802
|
+
throw new RelayrPaymentRetryError(UNNAMED, "invalid");
|
|
796
803
|
}
|
|
797
804
|
const reviewed = verifiablePayment(hashes, from, payment);
|
|
798
805
|
if (!reviewed)
|
|
@@ -834,6 +841,11 @@ export async function requireRelayrBundleUnpaid(bundleUuid, { fetch: fetchBundle
|
|
|
834
841
|
}
|
|
835
842
|
requireUnpaidBundle(await readBundle(fetchBundle, uuid, () => new RelayrPaymentRetryError(UNKNOWN_PAYMENT, "unknown")));
|
|
836
843
|
}
|
|
844
|
+
/** Relayr has not run the call: `pending`, in any case, with no destination hash. */
|
|
845
|
+
function relayrRecordPending(record) {
|
|
846
|
+
return (relayrDestinationHash(record) === null &&
|
|
847
|
+
relayrStateIsPending(record?.status?.state));
|
|
848
|
+
}
|
|
837
849
|
/** {@link requireRelayrBundleUnpaid}'s check of a bundle already read. */
|
|
838
850
|
function requireUnpaidBundle({ payment_received, transactions, }) {
|
|
839
851
|
if (payment_received === true) {
|
|
@@ -844,8 +856,7 @@ function requireUnpaidBundle({ payment_received, transactions, }) {
|
|
|
844
856
|
!transactions.length) {
|
|
845
857
|
throw new RelayrPaymentRetryError(UNKNOWN_PAYMENT, "unknown");
|
|
846
858
|
}
|
|
847
|
-
if (transactions.some((record) =>
|
|
848
|
-
!relayrStateIsPending(record?.status?.state))) {
|
|
859
|
+
if (transactions.some((record) => !relayrRecordPending(record))) {
|
|
849
860
|
throw new RelayrPaymentRetryError("Relayr reports a transaction of this bundle as running or run. Do not pay again.", "running");
|
|
850
861
|
}
|
|
851
862
|
}
|
|
@@ -959,4 +970,575 @@ export async function verifyRelayrDestinations(clientFor, { bindings, records, a
|
|
|
959
970
|
}
|
|
960
971
|
return verified;
|
|
961
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* `read` at the chain's finalized block, with that block's timestamp, once
|
|
975
|
+
* the block at its number still has the same hash after the read. Null while
|
|
976
|
+
* any of it cannot be read: a node without the finalized tag, an RPC error, a
|
|
977
|
+
* malformed block, or a block that is no longer canonical.
|
|
978
|
+
*/
|
|
979
|
+
export async function atCanonicalFinalizedBlock(client, read) {
|
|
980
|
+
try {
|
|
981
|
+
const { number, hash, timestamp } = await client.getBlock({
|
|
982
|
+
blockTag: "finalized",
|
|
983
|
+
});
|
|
984
|
+
if (typeof number !== "bigint" ||
|
|
985
|
+
!isBytes32(hash) ||
|
|
986
|
+
typeof timestamp !== "bigint") {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
const value = await read(number);
|
|
990
|
+
const canonical = await client.getBlock({ blockNumber: number });
|
|
991
|
+
return canonical.hash === hash ? { value, timestamp } : null;
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/** `clientFor(chainId)`, or none when it has none or throws. */
|
|
998
|
+
function clientOn(clientFor, chainId) {
|
|
999
|
+
try {
|
|
1000
|
+
return clientFor(chainId);
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* The nonce the canonical forwarder on `chainId` expects next from `signer`
|
|
1008
|
+
* at the chain's finalized block, still canonical, with that block's
|
|
1009
|
+
* timestamp. Null while that cannot be read.
|
|
1010
|
+
*/
|
|
1011
|
+
async function finalizedForwarderNonce(clientFor, chainId, signer) {
|
|
1012
|
+
const forwarder = forwarderOn(chainId);
|
|
1013
|
+
const reader = forwarder && isAddressLike(signer)
|
|
1014
|
+
? clientOn(clientFor, chainId)
|
|
1015
|
+
: undefined;
|
|
1016
|
+
if (!forwarder || !reader)
|
|
1017
|
+
return null;
|
|
1018
|
+
const finalized = await atCanonicalFinalizedBlock(reader, (blockNumber) => reader.readContract({
|
|
1019
|
+
address: forwarder,
|
|
1020
|
+
abi: erc2771ForwarderAbi,
|
|
1021
|
+
functionName: "nonces",
|
|
1022
|
+
args: [signer],
|
|
1023
|
+
blockNumber,
|
|
1024
|
+
}));
|
|
1025
|
+
const nonce = finalized && uint256(finalized.value);
|
|
1026
|
+
return finalized && nonce !== null
|
|
1027
|
+
? { nonce, timestamp: finalized.timestamp }
|
|
1028
|
+
: null;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* The requests a session published, each read from its entry (an `execute`
|
|
1032
|
+
* on its chain's canonical forwarder, as {@link relayrForwardRequest} reads
|
|
1033
|
+
* it), with the nonce it was signed with when `nonces` has one for every
|
|
1034
|
+
* entry. Null when there are none, or one is not such a request: requests
|
|
1035
|
+
* that can't be read can't be classified, so they never count as dead.
|
|
1036
|
+
*/
|
|
1037
|
+
export function relayrSignedRequests(entries, nonces) {
|
|
1038
|
+
const published = Array.isArray(entries) ? entries : [];
|
|
1039
|
+
const saved = Array.isArray(nonces) && nonces.length === published.length
|
|
1040
|
+
? nonces
|
|
1041
|
+
: undefined;
|
|
1042
|
+
const requests = published.flatMap((entry, index) => {
|
|
1043
|
+
const request = relayrForwardRequest(entry);
|
|
1044
|
+
return request
|
|
1045
|
+
? [
|
|
1046
|
+
{
|
|
1047
|
+
chainId: entry.chain,
|
|
1048
|
+
signer: request.from,
|
|
1049
|
+
deadline: request.deadline,
|
|
1050
|
+
...(saved ? { nonce: saved[index] } : {}),
|
|
1051
|
+
},
|
|
1052
|
+
]
|
|
1053
|
+
: [];
|
|
1054
|
+
});
|
|
1055
|
+
return published.length && requests.length === published.length
|
|
1056
|
+
? requests
|
|
1057
|
+
: null;
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* The one classification of signed forward requests (ruling R114): each at a
|
|
1061
|
+
* canonical finalized block on its chain, read once per chain and signer with
|
|
1062
|
+
* `clientFor(chainId)`. Never throws for a chain it cannot read: its requests
|
|
1063
|
+
* are live.
|
|
1064
|
+
*/
|
|
1065
|
+
export async function relayrRequestStates(clientFor, requests) {
|
|
1066
|
+
const finalized = new Map();
|
|
1067
|
+
return Promise.all(requests.map(async ({ chainId, signer, deadline, nonce, }) => {
|
|
1068
|
+
const key = `${chainId}:${String(signer).toLowerCase()}`;
|
|
1069
|
+
if (!finalized.has(key)) {
|
|
1070
|
+
finalized.set(key, finalizedForwarderNonce(clientFor, chainId, signer));
|
|
1071
|
+
}
|
|
1072
|
+
const block = await finalized.get(key);
|
|
1073
|
+
// A saved nonce that can't be read leaves the request unknown, and a
|
|
1074
|
+
// deadline that can't be read has not passed.
|
|
1075
|
+
const saved = nonce === undefined ? null : uint256(nonce);
|
|
1076
|
+
const end = uint256(deadline);
|
|
1077
|
+
if (block && (nonce === undefined || saved !== null)) {
|
|
1078
|
+
if (saved !== null && block.nonce > saved) {
|
|
1079
|
+
return { live: false, mayHaveRun: true, unused: false };
|
|
1080
|
+
}
|
|
1081
|
+
if (end !== null && end < block.timestamp) {
|
|
1082
|
+
return {
|
|
1083
|
+
live: false,
|
|
1084
|
+
mayHaveRun: saved === null,
|
|
1085
|
+
unused: block.nonce === saved,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return { live: true, deadline: Number(deadline) };
|
|
1090
|
+
}));
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* What `states` allow together. No states are neither run nor unused, so a
|
|
1094
|
+
* session with none holds.
|
|
1095
|
+
*/
|
|
1096
|
+
export function relayrRequestsVerdict(states) {
|
|
1097
|
+
const deadlines = states.flatMap((state) => state.live ? [state.deadline] : []);
|
|
1098
|
+
const mayHaveRun = states.some((state) => !state.live && state.mayHaveRun);
|
|
1099
|
+
if (deadlines.length) {
|
|
1100
|
+
return { live: true, until: Math.max(...deadlines), mayHaveRun };
|
|
1101
|
+
}
|
|
1102
|
+
return {
|
|
1103
|
+
live: false,
|
|
1104
|
+
mayHaveRun,
|
|
1105
|
+
unused: states.length > 0 && states.every((state) => !state.live && state.unused),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Ruling R117: whether every request is dead at a canonical finalized block
|
|
1110
|
+
* on its chain, anything unknown counting as live. A saved session reserves
|
|
1111
|
+
* its signers' forwarder nonces on its chains exactly while one of its
|
|
1112
|
+
* requests is live, never by a device clock or a quote's expiry. Requests that
|
|
1113
|
+
* can't be classified (null) or none at all are never dead.
|
|
1114
|
+
*/
|
|
1115
|
+
export async function relayrRequestsDead(clientFor, requests) {
|
|
1116
|
+
return (Array.isArray(requests) &&
|
|
1117
|
+
requests.length > 0 &&
|
|
1118
|
+
!relayrRequestsVerdict(await relayrRequestStates(clientFor, requests)).live);
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Whether the chain's finalized block, still canonical, is past `deadline`
|
|
1122
|
+
* (seconds, as a safe integer, decimal or 0x-hex digits, or a bigint): its
|
|
1123
|
+
* timestamp is later. False while that is unknown, or the deadline can't be
|
|
1124
|
+
* read.
|
|
1125
|
+
*/
|
|
1126
|
+
export async function relayrDeadlinePassed(client, deadline) {
|
|
1127
|
+
const finalized = await atCanonicalFinalizedBlock(client, async () => undefined);
|
|
1128
|
+
const end = uint256(deadline);
|
|
1129
|
+
return !!finalized && end !== null && finalized.timestamp > end;
|
|
1130
|
+
}
|
|
1131
|
+
/** Whether `value` is a {@link RelayrDiscardReason}, for a reason read back from storage. */
|
|
1132
|
+
export function isRelayrDiscardReason(value) {
|
|
1133
|
+
return value === "ran" || value === "changed" || value === "expired";
|
|
1134
|
+
}
|
|
1135
|
+
/** The transport failures that mean a recheck could not reach the chain. */
|
|
1136
|
+
const UNREACHED = ["HttpRequestError", "TimeoutError", "WebSocketRequestError"];
|
|
1137
|
+
/**
|
|
1138
|
+
* Revert data: whole bytes of hex, at least a 4-byte selector, bare or in
|
|
1139
|
+
* Nethermind's "Reverted 0x…" form.
|
|
1140
|
+
*/
|
|
1141
|
+
const REVERT_DATA = /^(?:Reverted )?0x(?:[0-9a-fA-F]{2}){4,}$/u;
|
|
1142
|
+
/**
|
|
1143
|
+
* Whether one error carries revert data, whatever its JSON-RPC code: in its
|
|
1144
|
+
* `data`, in a `data.data` a node nests there, or in the `raw` data of viem's
|
|
1145
|
+
* contract revert. Such an error is the chain answering.
|
|
1146
|
+
*/
|
|
1147
|
+
function revertedWithData(link) {
|
|
1148
|
+
const { raw, data } = link;
|
|
1149
|
+
const nested = data !== null && typeof data === "object"
|
|
1150
|
+
? data.data
|
|
1151
|
+
: undefined;
|
|
1152
|
+
return [raw, data, nested].some((value) => typeof value === "string" && REVERT_DATA.test(value));
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Ruling R118: the node could not answer the recheck, so it says nothing
|
|
1156
|
+
* about the project. Within the error's first eight links (its cause chain),
|
|
1157
|
+
* one is a transport failure (HTTP, timeout or WebSocket), a JSON-RPC failure
|
|
1158
|
+
* (viem's `RpcRequestError` or any `RpcError`, such as -32001, -32005 or
|
|
1159
|
+
* -32603), or a contract revert without revert data, as viem reads a bare
|
|
1160
|
+
* revert, transient or not; and no link carries revert data, which on any
|
|
1161
|
+
* code is the chain answering. An app's error that wraps such a failure as
|
|
1162
|
+
* its `cause` reads the same way.
|
|
1163
|
+
*/
|
|
1164
|
+
function recheckUnreached(error) {
|
|
1165
|
+
const links = [];
|
|
1166
|
+
for (let link = error; links.length < 8 && link instanceof Error; link = link.cause) {
|
|
1167
|
+
links.push(link);
|
|
1168
|
+
}
|
|
1169
|
+
return (!links.some(revertedWithData) &&
|
|
1170
|
+
links.some((link) => UNREACHED.includes(link.name) ||
|
|
1171
|
+
link instanceof RpcRequestError ||
|
|
1172
|
+
link instanceof RpcError ||
|
|
1173
|
+
link instanceof ContractFunctionRevertedError));
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* What a session does next from its requests' `verdict` (see
|
|
1177
|
+
* {@link RelayrSessionOutcome}), which was classified before the action's own
|
|
1178
|
+
* recheck, since a request that ran would make the recheck refuse.
|
|
1179
|
+
*
|
|
1180
|
+
* `nonces` must be the saved nonces of exactly the requests that were
|
|
1181
|
+
* classified into `verdict`, in the same order: `refresh` and `re-sign` hand
|
|
1182
|
+
* them back to sign at, and nothing here can check them against the verdict.
|
|
1183
|
+
* Without them (omitted, empty or not a list), a session whose requests are
|
|
1184
|
+
* all dead and unused holds as `reorg-hold`, and is neither discarded nor
|
|
1185
|
+
* signed again.
|
|
1186
|
+
*
|
|
1187
|
+
* `recheck`, the action's proof that its calls still apply, resolves when
|
|
1188
|
+
* they do and throws when they don't; a value it resolves with is never read.
|
|
1189
|
+
* It runs only once every request is dead and unused. Without one, as in an
|
|
1190
|
+
* account view, such a session can be discarded as `expired`. A recheck that
|
|
1191
|
+
* fails because the node could not answer decides nothing. Known limit: a
|
|
1192
|
+
* reorg that drops an earlier forwarded transaction can leave a finalized
|
|
1193
|
+
* nonce below a saved one, and the session holds until the nonce catches up.
|
|
1194
|
+
*/
|
|
1195
|
+
export async function relayrSessionOutcome(verdict, { nonces, recheck, } = {}) {
|
|
1196
|
+
const saved = Array.isArray(nonces) && nonces.length ? [...nonces] : null;
|
|
1197
|
+
if (verdict.live) {
|
|
1198
|
+
return verdict.mayHaveRun
|
|
1199
|
+
? { kind: "hold", until: verdict.until }
|
|
1200
|
+
: { kind: "refresh", until: verdict.until, nonces: saved };
|
|
1201
|
+
}
|
|
1202
|
+
if (verdict.mayHaveRun)
|
|
1203
|
+
return { kind: "discard", reason: "ran" };
|
|
1204
|
+
if (!verdict.unused || !saved)
|
|
1205
|
+
return { kind: "reorg-hold" };
|
|
1206
|
+
if (!recheck)
|
|
1207
|
+
return { kind: "discard", reason: "expired" };
|
|
1208
|
+
try {
|
|
1209
|
+
await recheck();
|
|
1210
|
+
}
|
|
1211
|
+
catch (error) {
|
|
1212
|
+
return recheckUnreached(error)
|
|
1213
|
+
? withHidden({ kind: "unchecked" }, "error", error)
|
|
1214
|
+
: withHidden({ kind: "discard", reason: "changed" }, "error", error);
|
|
1215
|
+
}
|
|
1216
|
+
return { kind: "re-sign", nonces: saved };
|
|
1217
|
+
}
|
|
1218
|
+
/** The payment `details` describes, as a session records it once sent under `hash`. */
|
|
1219
|
+
export function sentRelayrPayment(details, hash) {
|
|
1220
|
+
return {
|
|
1221
|
+
...details,
|
|
1222
|
+
amount: details.amount.toString(),
|
|
1223
|
+
deadline: details.deadline.toString(),
|
|
1224
|
+
hash,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/** The most payments one quote's journal keeps. No payment is sent beyond them. */
|
|
1228
|
+
export const MAX_RELAYR_SENT_PAYMENTS = 16;
|
|
1229
|
+
/**
|
|
1230
|
+
* A saved sent payment, read strictly, or null. Its deadline must be the one
|
|
1231
|
+
* its calldata pays until, which the payment's proof binds onchain.
|
|
1232
|
+
*/
|
|
1233
|
+
function relayrSentPaymentSnapshot(value) {
|
|
1234
|
+
if (!value || typeof value !== "object")
|
|
1235
|
+
return null;
|
|
1236
|
+
const { hash, chainId, target, calldata, amount, deadline, bundleUuid } = value;
|
|
1237
|
+
return isBytes32(hash) &&
|
|
1238
|
+
typeof chainId === "number" &&
|
|
1239
|
+
relayrSupportsChain(chainId) &&
|
|
1240
|
+
isStrictAddress(target) &&
|
|
1241
|
+
typeof calldata === "string" &&
|
|
1242
|
+
/^0x[0-9a-f]{136}$/iu.test(calldata) &&
|
|
1243
|
+
typeof amount === "string" &&
|
|
1244
|
+
/^\d{1,78}$/u.test(amount) &&
|
|
1245
|
+
typeof deadline === "string" &&
|
|
1246
|
+
/^\d{1,13}$/u.test(deadline) &&
|
|
1247
|
+
typeof bundleUuid === "string" &&
|
|
1248
|
+
RELAYR_UUID_RE.test(bundleUuid) &&
|
|
1249
|
+
BigInt(deadline) === calldataDeadline(calldata)
|
|
1250
|
+
? {
|
|
1251
|
+
hash,
|
|
1252
|
+
chainId,
|
|
1253
|
+
target,
|
|
1254
|
+
calldata: calldata,
|
|
1255
|
+
amount,
|
|
1256
|
+
deadline,
|
|
1257
|
+
bundleUuid,
|
|
1258
|
+
}
|
|
1259
|
+
: null;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* A saved list of sent payments, read strictly: at most
|
|
1263
|
+
* {@link MAX_RELAYR_SENT_PAYMENTS}, each with only a payment's own fields.
|
|
1264
|
+
* Null when any of it is malformed.
|
|
1265
|
+
*/
|
|
1266
|
+
export function relayrSentPaymentsSnapshot(values) {
|
|
1267
|
+
if (!Array.isArray(values) || values.length > MAX_RELAYR_SENT_PAYMENTS) {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
const payments = values.map(relayrSentPaymentSnapshot);
|
|
1271
|
+
return payments.every((payment) => payment !== null)
|
|
1272
|
+
? payments
|
|
1273
|
+
: null;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* The saved option a quote paid before is paid again with: exactly the one
|
|
1277
|
+
* its latest payment used, on its chain with its calldata (in any case) and
|
|
1278
|
+
* amount. Throws when no option is that one, and when an option on that
|
|
1279
|
+
* chain with that calldata whose amount can't be read comes before it.
|
|
1280
|
+
*/
|
|
1281
|
+
export function relayrRetryOption(payments, options) {
|
|
1282
|
+
const latest = Array.isArray(payments)
|
|
1283
|
+
? payments[payments.length - 1]
|
|
1284
|
+
: undefined;
|
|
1285
|
+
let option;
|
|
1286
|
+
if (latest && Array.isArray(options)) {
|
|
1287
|
+
for (const item of options) {
|
|
1288
|
+
if (item.chain !== latest.chainId ||
|
|
1289
|
+
typeof item.calldata !== "string" ||
|
|
1290
|
+
item.calldata.toLowerCase() !== latest.calldata.toLowerCase() ||
|
|
1291
|
+
typeof item.amount !== "string") {
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
// An earlier twin whose amount can't be read ends the search, refused.
|
|
1295
|
+
const amount = uint256(item.amount);
|
|
1296
|
+
if (amount === null)
|
|
1297
|
+
break;
|
|
1298
|
+
if (amount === uint256(latest.amount)) {
|
|
1299
|
+
option = item;
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
if (!option) {
|
|
1305
|
+
throw new Error("This Relayr quote cannot be paid again from its saved record. Keep it pending; do not pay again.");
|
|
1306
|
+
}
|
|
1307
|
+
return option;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Where a failed payment attempt leaves its quote: `reverted` when the
|
|
1311
|
+
* payment reverted onchain, or when the wallet declined to pay a quote paid
|
|
1312
|
+
* before, which stays on {@link requireRelayrPaymentRetry}'s rule; `unpaid`
|
|
1313
|
+
* when the wallet declined its first payment; null when nothing is known, and
|
|
1314
|
+
* the journal stays as it is. `sending` is whether the wallet held the
|
|
1315
|
+
* payment, and `paid` whether the quote was paid before.
|
|
1316
|
+
*/
|
|
1317
|
+
export function relayrPaymentAttemptOutcome(error, { sending, paid }) {
|
|
1318
|
+
if (error instanceof RelayrPaymentRevertedError)
|
|
1319
|
+
return "reverted";
|
|
1320
|
+
if (sending && isDefiniteWalletRejection(error)) {
|
|
1321
|
+
return paid ? "reverted" : "unpaid";
|
|
1322
|
+
}
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Clear a quote paid before for one more payment, with
|
|
1327
|
+
* {@link requireRelayrPaymentRetry}'s rule for each option its payments used.
|
|
1328
|
+
* `payments` is every payment the session sent for the quote, as mined; those
|
|
1329
|
+
* of one option (its chain, its calldata in any case, and its amount) are
|
|
1330
|
+
* proven together on its chain, with `clientFor`. Before reading anything it
|
|
1331
|
+
* refuses, as `invalid`, an empty list and anything
|
|
1332
|
+
* {@link relayrSentPaymentsSnapshot} refuses, and a payment of another
|
|
1333
|
+
* bundle; a chain without a client is `unknown`. Never pay on an error.
|
|
1334
|
+
*/
|
|
1335
|
+
export async function requireRelayrRetry(clientFor, { payments, from, bundleUuid, }, options = {}) {
|
|
1336
|
+
if (!Array.isArray(payments) || !payments.length) {
|
|
1337
|
+
throw new RelayrPaymentRetryError(UNNAMED, "invalid");
|
|
1338
|
+
}
|
|
1339
|
+
const sent = relayrSentPaymentsSnapshot(payments);
|
|
1340
|
+
if (!sent)
|
|
1341
|
+
throw new RelayrPaymentRetryError(UNVERIFIABLE, "invalid");
|
|
1342
|
+
if (sent.some((payment) => payment.bundleUuid !== bundleUuid)) {
|
|
1343
|
+
throw new Error("A saved Relayr payment belongs to another bundle. Do not pay again; check the original bundle.");
|
|
1344
|
+
}
|
|
1345
|
+
const byPayment = new Map();
|
|
1346
|
+
for (const payment of sent) {
|
|
1347
|
+
const key = `${payment.chainId}:${payment.calldata.toLowerCase()}:${payment.amount}`;
|
|
1348
|
+
const group = byPayment.get(key) ?? { payment, hashes: [] };
|
|
1349
|
+
group.hashes.push(payment.hash);
|
|
1350
|
+
byPayment.set(key, group);
|
|
1351
|
+
}
|
|
1352
|
+
for (const { payment, hashes } of byPayment.values()) {
|
|
1353
|
+
const client = clientOn(clientFor, payment.chainId);
|
|
1354
|
+
if (!client) {
|
|
1355
|
+
throw new RelayrPaymentRetryError(`No RPC is available for chain ${payment.chainId}. Do not pay again yet; check it later.`, "unknown");
|
|
1356
|
+
}
|
|
1357
|
+
await requireRelayrPaymentRetry(client, { hashes, from, payment }, options);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Prove a saved session's latest payment when it resumes, on its chain with
|
|
1362
|
+
* `clientFor`. Resolves true once it succeeded onchain, and false while that
|
|
1363
|
+
* can't be proven: no payment, an `account` that is not an address, no client
|
|
1364
|
+
* for its chain, or a read that failed. A canonical revert runs `onReverted`
|
|
1365
|
+
* and is thrown, as is any other {@link RelayrProofError}; the quote then
|
|
1366
|
+
* waits on {@link requireRelayrPaymentRetry}'s rule.
|
|
1367
|
+
*/
|
|
1368
|
+
export async function proveSavedRelayrPayment(clientFor, payments, account, onReverted) {
|
|
1369
|
+
const latest = Array.isArray(payments)
|
|
1370
|
+
? payments[payments.length - 1]
|
|
1371
|
+
: undefined;
|
|
1372
|
+
if (!latest || !isStrictAddress(account))
|
|
1373
|
+
return false;
|
|
1374
|
+
const client = clientOn(clientFor, latest.chainId);
|
|
1375
|
+
if (!client)
|
|
1376
|
+
return false;
|
|
1377
|
+
try {
|
|
1378
|
+
await verifyRelayrPayment(client, {
|
|
1379
|
+
hash: latest.hash,
|
|
1380
|
+
from: account,
|
|
1381
|
+
payment: latest,
|
|
1382
|
+
});
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
if (error instanceof RelayrPaymentRevertedError)
|
|
1387
|
+
onReverted();
|
|
1388
|
+
if (error instanceof RelayrProofError)
|
|
1389
|
+
throw error;
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Whether the quote a session paid can still be paid at `nowMs` (the clock by
|
|
1395
|
+
* default): its latest payment's deadline is more than 15 seconds away, as
|
|
1396
|
+
* {@link quoteExpired} reads it and {@link requireRelayrPaymentRetry}
|
|
1397
|
+
* requires. A deadline or a time that can't be read is closed.
|
|
1398
|
+
*/
|
|
1399
|
+
export function relayrPaidQuoteOpen(payments, nowMs = Date.now()) {
|
|
1400
|
+
const latest = Array.isArray(payments)
|
|
1401
|
+
? payments[payments.length - 1]
|
|
1402
|
+
: undefined;
|
|
1403
|
+
const deadline = latest ? uint256(latest.deadline) : null;
|
|
1404
|
+
return (deadline !== null &&
|
|
1405
|
+
Number.isFinite(nowMs) &&
|
|
1406
|
+
!quoteExpired(deadline, nowMs / 1_000));
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Every option of a quote that {@link relayrPaymentDetails} accepts, expired
|
|
1410
|
+
* or not, several on one chain included, with its details: the options a
|
|
1411
|
+
* session keeps, and whose deadlines a release waits out. An option nothing
|
|
1412
|
+
* can authenticate is never paid from, so it is left out.
|
|
1413
|
+
*/
|
|
1414
|
+
export function relayrQuotedOptions(quote, destinationChainIds) {
|
|
1415
|
+
return (Array.isArray(quote.payment_info) ? quote.payment_info : []).flatMap((option) => {
|
|
1416
|
+
try {
|
|
1417
|
+
return [
|
|
1418
|
+
{
|
|
1419
|
+
option,
|
|
1420
|
+
details: relayrPaymentDetails(option, {
|
|
1421
|
+
bundleUuid: quote.bundle_uuid,
|
|
1422
|
+
destinationChainIds,
|
|
1423
|
+
nowSeconds: 0,
|
|
1424
|
+
}),
|
|
1425
|
+
},
|
|
1426
|
+
];
|
|
1427
|
+
}
|
|
1428
|
+
catch {
|
|
1429
|
+
return [];
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
/** One uncached read of the bundle, or null when it can't be read, names another bundle or lists no calls. */
|
|
1434
|
+
async function relayrBundleIfNamed(bundleUuid, fetchBundle) {
|
|
1435
|
+
try {
|
|
1436
|
+
const { payment_received, transactions } = await readRelayrBundle(bundleUuid, { fetch: fetchBundle });
|
|
1437
|
+
return Array.isArray(transactions)
|
|
1438
|
+
? { paymentReceived: payment_received, records: transactions }
|
|
1439
|
+
: null;
|
|
1440
|
+
}
|
|
1441
|
+
catch {
|
|
1442
|
+
return null;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Nothing can fund the quote any more: every payment it sent is proven
|
|
1447
|
+
* canonically reverted, and the deadline of each of those payments, and of
|
|
1448
|
+
* each option {@link relayrQuotedOptions} keeps, has passed at a canonical
|
|
1449
|
+
* finalized block on its chain. Anything unread is false.
|
|
1450
|
+
*/
|
|
1451
|
+
async function relayrQuoteUnfundable(clientFor, { payments, options, bundleUuid, destinationChainIds, account, }) {
|
|
1452
|
+
if (!Array.isArray(payments) ||
|
|
1453
|
+
!payments.length ||
|
|
1454
|
+
!Array.isArray(options) ||
|
|
1455
|
+
!isStrictAddress(account)) {
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
for (const payment of payments) {
|
|
1459
|
+
const client = clientOn(clientFor, payment.chainId);
|
|
1460
|
+
if (!client)
|
|
1461
|
+
return false;
|
|
1462
|
+
try {
|
|
1463
|
+
await verifyRelayrPayment(client, {
|
|
1464
|
+
hash: payment.hash,
|
|
1465
|
+
from: account,
|
|
1466
|
+
payment,
|
|
1467
|
+
});
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
if (!(error instanceof RelayrPaymentRevertedError))
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
// The proof read the calldata's deadline word; the saved one must be it.
|
|
1475
|
+
if (uint256(payment.deadline) !== calldataDeadline(payment.calldata)) {
|
|
1476
|
+
return false;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
const deadlines = new Map(payments.map((payment) => [
|
|
1480
|
+
`${payment.chainId}:${payment.deadline}`,
|
|
1481
|
+
payment,
|
|
1482
|
+
]));
|
|
1483
|
+
for (const { details } of relayrQuotedOptions({ bundle_uuid: bundleUuid, payment_info: [...options] }, destinationChainIds)) {
|
|
1484
|
+
deadlines.set(`${details.chainId}:${details.deadline}`, {
|
|
1485
|
+
chainId: details.chainId,
|
|
1486
|
+
deadline: details.deadline.toString(),
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
for (const { chainId, deadline } of deadlines.values()) {
|
|
1490
|
+
const client = clientOn(clientFor, chainId);
|
|
1491
|
+
if (!client || !(await relayrDeadlinePassed(client, deadline))) {
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return true;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* What a quote whose own payments reverted allows (ruling R104), from one
|
|
1499
|
+
* uncached read of its bundle (with `fetch`, the global one by default):
|
|
1500
|
+
*
|
|
1501
|
+
* - `funded` when Relayr reports a payment, or a call running or run: another
|
|
1502
|
+
* payment funded it, so its destinations are proven, never paid again;
|
|
1503
|
+
* - `payable` while the quote of its latest payment is open at `nowMs` (the
|
|
1504
|
+
* clock, read after the bundle, by default), for
|
|
1505
|
+
* {@link requireRelayrPaymentRetry}'s rule;
|
|
1506
|
+
* - `released` once nothing can fund it: every payment it sent is proven
|
|
1507
|
+
* canonically reverted on its chain (with `clientFor`), every deadline of
|
|
1508
|
+
* those payments and of the options {@link relayrQuotedOptions} keeps
|
|
1509
|
+
* passed at a canonical finalized block, and
|
|
1510
|
+
* {@link requireRelayrBundleUnpaid}, reading the bundle once more, finds it
|
|
1511
|
+
* unpaid with every call pending. Its action then quotes its calls again.
|
|
1512
|
+
*
|
|
1513
|
+
* Throws while an expired quote's release is unproven, and, unless the
|
|
1514
|
+
* bundle is funded, for a payment of another bundle or a time that can't be
|
|
1515
|
+
* read. Resolves with the records Relayr reported, or null when the bundle
|
|
1516
|
+
* could not be read.
|
|
1517
|
+
*/
|
|
1518
|
+
export async function revertedRelayrQuote(clientFor, quote, { fetch: fetchBundle = globalThis.fetch, nowMs, } = {}) {
|
|
1519
|
+
const bundle = await relayrBundleIfNamed(quote.bundleUuid, fetchBundle);
|
|
1520
|
+
const records = bundle?.records ?? null;
|
|
1521
|
+
if (bundle &&
|
|
1522
|
+
(bundle.paymentReceived === true ||
|
|
1523
|
+
bundle.records.some((record) => !relayrRecordPending(record)))) {
|
|
1524
|
+
return { state: "funded", records };
|
|
1525
|
+
}
|
|
1526
|
+
// The clock is read after the bundle, whose read can take a while.
|
|
1527
|
+
const now = nowMs ?? Date.now();
|
|
1528
|
+
const ownPayments = Array.isArray(quote.payments) &&
|
|
1529
|
+
quote.payments.every((payment) => payment?.bundleUuid === quote.bundleUuid);
|
|
1530
|
+
if (ownPayments && Number.isFinite(now)) {
|
|
1531
|
+
if (relayrPaidQuoteOpen(quote.payments, now)) {
|
|
1532
|
+
return { state: "payable", records };
|
|
1533
|
+
}
|
|
1534
|
+
if (bundle &&
|
|
1535
|
+
(await relayrQuoteUnfundable(clientFor, quote)) &&
|
|
1536
|
+
(await requireRelayrBundleUnpaid(quote.bundleUuid, {
|
|
1537
|
+
fetch: fetchBundle,
|
|
1538
|
+
}).then(() => true, () => false))) {
|
|
1539
|
+
return { state: "released", records };
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
throw new Error("This Relayr quote expired after its payment reverted. A new quote needs its deadline final onchain and Relayr to report nothing ran; try again in a few minutes.");
|
|
1543
|
+
}
|
|
962
1544
|
//# sourceMappingURL=relayr.js.map
|