@getomnichain/omnichain 0.2.1 → 0.3.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/chain.base.js.map +1 -1
- package/dist/cjs/errors.js +55 -6
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/evm/evm_chain.js +421 -2
- package/dist/cjs/evm/evm_chain.js.map +1 -1
- package/dist/cjs/evm/evm_transaction_status.js +4 -0
- package/dist/cjs/evm/evm_transaction_status.js.map +1 -1
- package/dist/cjs/evm/unsigned_evm_transaction.js +12 -0
- package/dist/cjs/evm/unsigned_evm_transaction.js.map +1 -1
- package/dist/cjs/solana/solana_chain.js +618 -21
- package/dist/cjs/solana/solana_chain.js.map +1 -1
- package/dist/cjs/solana/unsigned_solana_transaction.js +33 -0
- package/dist/cjs/solana/unsigned_solana_transaction.js.map +1 -1
- package/dist/cjs/utxo/utxo_chain.js +94 -10
- package/dist/cjs/utxo/utxo_chain.js.map +1 -1
- package/dist/esm/chain.base.d.ts +38 -1
- package/dist/esm/chain.base.d.ts.map +1 -1
- package/dist/esm/chain.base.js.map +1 -1
- package/dist/esm/errors.d.ts +13 -0
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +51 -6
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/evm/evm_chain.d.ts +48 -2
- package/dist/esm/evm/evm_chain.d.ts.map +1 -1
- package/dist/esm/evm/evm_chain.js +423 -4
- package/dist/esm/evm/evm_chain.js.map +1 -1
- package/dist/esm/evm/evm_transaction_status.d.ts +4 -0
- package/dist/esm/evm/evm_transaction_status.d.ts.map +1 -1
- package/dist/esm/evm/evm_transaction_status.js +4 -0
- package/dist/esm/evm/evm_transaction_status.js.map +1 -1
- package/dist/esm/evm/unsigned_evm_transaction.d.ts +13 -0
- package/dist/esm/evm/unsigned_evm_transaction.d.ts.map +1 -1
- package/dist/esm/evm/unsigned_evm_transaction.js +12 -0
- package/dist/esm/evm/unsigned_evm_transaction.js.map +1 -1
- package/dist/esm/solana/solana_chain.d.ts +87 -3
- package/dist/esm/solana/solana_chain.d.ts.map +1 -1
- package/dist/esm/solana/solana_chain.js +618 -23
- package/dist/esm/solana/solana_chain.js.map +1 -1
- package/dist/esm/solana/unsigned_solana_transaction.d.ts +20 -1
- package/dist/esm/solana/unsigned_solana_transaction.d.ts.map +1 -1
- package/dist/esm/solana/unsigned_solana_transaction.js +33 -0
- package/dist/esm/solana/unsigned_solana_transaction.js.map +1 -1
- package/dist/esm/utxo/utxo_chain.d.ts +15 -2
- package/dist/esm/utxo/utxo_chain.d.ts.map +1 -1
- package/dist/esm/utxo/utxo_chain.js +96 -13
- package/dist/esm/utxo/utxo_chain.js.map +1 -1
- package/docs/CONNECTIONS.md +33 -0
- package/docs/EIP7702.md +77 -0
- package/docs/JITO.md +41 -0
- package/docs/MULTI_RPC.md +28 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AbiCoder, Contract, Interface, JsonRpcProvider, } from 'ethers';
|
|
1
|
+
import { AbiCoder, Contract, Interface, JsonRpcProvider, concat, encodeRlp, getAddress, getBytes, hexlify, keccak256, toBeArray, } from 'ethers';
|
|
2
2
|
import { NetworkType, tryNetworkTypeOf } from "../network_type.js";
|
|
3
|
-
import { Chain, resolveTransferAmount } from "../chain.base.js";
|
|
3
|
+
import { Chain, resolveTransferAmount, } from "../chain.base.js";
|
|
4
4
|
import { ChainError, ChainErrorKinds, sanitizeCause, sanitizeMessage } from "../errors.js";
|
|
5
5
|
import { Priority } from "../priority.js";
|
|
6
6
|
import { EvmGasEstimate } from "./evm_gas_estimate.js";
|
|
@@ -53,6 +53,7 @@ const ERC20_ABI = [
|
|
|
53
53
|
const ERC20_INTERFACE = new Interface(ERC20_ABI);
|
|
54
54
|
export class EvmChain extends Chain {
|
|
55
55
|
rpcUrl;
|
|
56
|
+
rpcUrls;
|
|
56
57
|
supportsEip1559;
|
|
57
58
|
hasL1Fee;
|
|
58
59
|
/**
|
|
@@ -70,6 +71,7 @@ export class EvmChain extends Chain {
|
|
|
70
71
|
* `nativeTransferGasLimit`.
|
|
71
72
|
*/
|
|
72
73
|
nativeTransferGasMultiplier;
|
|
74
|
+
supports7702;
|
|
73
75
|
_nativeToken;
|
|
74
76
|
_provider = null;
|
|
75
77
|
_resolvedRpcUrl = null;
|
|
@@ -86,10 +88,15 @@ export class EvmChain extends Chain {
|
|
|
86
88
|
}
|
|
87
89
|
super(init.chainId, init.name, NetworkType.EVM, init.blockTimeSeconds, init.nativeSymbol, init.explorerBaseUrl);
|
|
88
90
|
this.rpcUrl = init.rpcUrl;
|
|
91
|
+
this.rpcUrls = init.rpcUrls ?? [];
|
|
92
|
+
if (this.rpcUrls.length > 1) {
|
|
93
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `EvmChainInit.rpcUrls accepts only ONE endpoint in 0.3.0 (got ${this.rpcUrls.length}). Automatic failover retry is deferred to a follow-up release; passing >1 endpoint would silently discard entries.`, { chainId: init.chainId });
|
|
94
|
+
}
|
|
89
95
|
this.supportsEip1559 = init.supportsEip1559 ?? true;
|
|
90
96
|
this.hasL1Fee = init.hasL1Fee ?? false;
|
|
91
97
|
this.nativeTransferGasLimit = init.nativeTransferGasLimit ?? 21000;
|
|
92
98
|
this.nativeTransferGasMultiplier = init.nativeTransferGasMultiplier ?? 1.4;
|
|
99
|
+
this.supports7702 = init.supports7702 ?? false;
|
|
93
100
|
this._nativeToken = EvmToken.native(init.chainId, init.nativeSymbol, init.nativeDecimals ?? 18);
|
|
94
101
|
// NOTE: no `registerNonEvmChain(id, EVM)` call — a module-scope write on
|
|
95
102
|
// 48 pre-baked chains would let a consumer's earlier
|
|
@@ -232,6 +239,13 @@ export class EvmChain extends Chain {
|
|
|
232
239
|
readRpcUrl() {
|
|
233
240
|
if (this.rpcUrl && this.rpcUrl.trim().length > 0)
|
|
234
241
|
return this.rpcUrl.trim();
|
|
242
|
+
if (this.rpcUrls.length > 0) {
|
|
243
|
+
for (const candidate of this.rpcUrls) {
|
|
244
|
+
if (candidate && candidate.trim().length > 0)
|
|
245
|
+
return candidate.trim();
|
|
246
|
+
}
|
|
247
|
+
throw new ChainError(ChainErrorKinds.RpcNotConfigured, `${this.name}: rpcUrls was supplied but every entry is blank; refusing to fall back to env or public defaults`, { chainId: this.chainId });
|
|
248
|
+
}
|
|
235
249
|
const env = globalThis.process?.env;
|
|
236
250
|
const candidates = envCandidatesFor(this.name, this.chainId);
|
|
237
251
|
for (const key of candidates) {
|
|
@@ -239,7 +253,7 @@ export class EvmChain extends Chain {
|
|
|
239
253
|
if (value && value.trim().length > 0)
|
|
240
254
|
return value.trim();
|
|
241
255
|
}
|
|
242
|
-
throw new ChainError(ChainErrorKinds.RpcNotConfigured, `${this.name} RPC URL is not configured (pass rpcUrl at construction, or set one of: ${candidates.join(', ')})`, { chainId: this.chainId, envCandidates: candidates });
|
|
256
|
+
throw new ChainError(ChainErrorKinds.RpcNotConfigured, `${this.name} RPC URL is not configured (pass rpcUrl, rpcUrls at construction, or set one of: ${candidates.join(', ')})`, { chainId: this.chainId, envCandidates: candidates });
|
|
243
257
|
}
|
|
244
258
|
rpcHost() {
|
|
245
259
|
const url = this._resolvedRpcUrl ?? this.rpcUrl;
|
|
@@ -275,6 +289,15 @@ export class EvmChain extends Chain {
|
|
|
275
289
|
return false;
|
|
276
290
|
}
|
|
277
291
|
}
|
|
292
|
+
normalizeAddressOrThrow(raw, field) {
|
|
293
|
+
const body = raw.replace(/^0[xX]/, '');
|
|
294
|
+
try {
|
|
295
|
+
return getAddress(`0x${body}`);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM address for ${field}: ${raw}`, { chainId: this.chainId, address: raw }, err instanceof Error ? err : undefined);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
278
301
|
validateTokenIdentifier(raw) {
|
|
279
302
|
if (raw === undefined)
|
|
280
303
|
return true;
|
|
@@ -373,7 +396,323 @@ export class EvmChain extends Chain {
|
|
|
373
396
|
throw this.rpcError('Failed to read chain tip', err);
|
|
374
397
|
}
|
|
375
398
|
}
|
|
376
|
-
async
|
|
399
|
+
async broadcast(signed, opts) {
|
|
400
|
+
if (opts && opts.signal !== undefined) {
|
|
401
|
+
throw new ChainError(ChainErrorKinds.FeatureNotSupported, `EVM broadcast: signal is not honored in 0.3.0 (silently ignoring would let a caller conclude 'not sent' while the tx still lands). Cancellation returns in 0.3.1.`, { chainId: this.chainId });
|
|
402
|
+
}
|
|
403
|
+
let hex;
|
|
404
|
+
if (typeof signed === 'string') {
|
|
405
|
+
const stripped = signed.startsWith('0x') ? signed.slice(2) : signed;
|
|
406
|
+
if (!/^[0-9a-fA-F]+$/.test(stripped) || stripped.length % 2 !== 0 || stripped.length === 0) {
|
|
407
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `EVM broadcast: signed transaction must be 0x-prefixed hex or Uint8Array (got malformed string of length ${signed.length})`, { chainId: this.chainId });
|
|
408
|
+
}
|
|
409
|
+
hex = `0x${stripped}`;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
if (signed.length === 0) {
|
|
413
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `EVM broadcast: signed transaction bytes are empty`, { chainId: this.chainId });
|
|
414
|
+
}
|
|
415
|
+
hex = hexlify(signed);
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const resp = await this.getProvider().broadcastTransaction(hex);
|
|
419
|
+
return resp.hash;
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
const rawMsg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
423
|
+
if (/already[-_ ]?known|known\s+transaction|transaction[-_ ]?already[-_ ]?known|already\s+in\s+(?:the\s+)?(?:mempool|pool)|already[-_ ]?present/.test(rawMsg)) {
|
|
424
|
+
// Node "already known" is authoritative for THAT node's mempool —
|
|
425
|
+
// the identical signed bytes are pending or landed. Return the
|
|
426
|
+
// deterministic hash so the consumer stays on the polling path.
|
|
427
|
+
// Do NOT gate on a follow-up getTransaction read: load-balanced
|
|
428
|
+
// providers (Alchemy/Infura/QuickNode round-robin) routinely serve
|
|
429
|
+
// the confirmation read from a different backend that has not yet
|
|
430
|
+
// seen the pending tx → null → falsely-terminal BroadcastRejected
|
|
431
|
+
// → consumer re-signs against a fresh nonce → double-send while
|
|
432
|
+
// the original is live. If the tx really never lands, the
|
|
433
|
+
// consumer's getTransactionStatus poll will surface NotFound and
|
|
434
|
+
// the safe-broadcast rule (re-broadcast same bytes, never re-sign)
|
|
435
|
+
// still applies. Matches SolanaChain/UtxoChain already-known paths.
|
|
436
|
+
return keccak256(hex);
|
|
437
|
+
}
|
|
438
|
+
throw this.classifyBroadcastError(err);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async getPendingNonce(address) {
|
|
442
|
+
if (!this.validateAddress(address)) {
|
|
443
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM address for getPendingNonce: ${address}`, { chainId: this.chainId, address });
|
|
444
|
+
}
|
|
445
|
+
const normalized = this.normalizeAddressOrThrow(address, 'getPendingNonce.address');
|
|
446
|
+
try {
|
|
447
|
+
const n = await this.getProvider().getTransactionCount(normalized, 'pending');
|
|
448
|
+
return BigInt(n);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
throw this.rpcError(`Failed to read pending nonce for ${normalized}`, err, { address: normalized });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async getDelegation(address) {
|
|
455
|
+
if (!this.supports7702) {
|
|
456
|
+
throw new ChainError(ChainErrorKinds.FeatureNotSupported, `EVM chain ${this.name} was not initialized with supports7702: true`, { chainId: this.chainId });
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
getAddress(address);
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM address: ${address}`, { chainId: this.chainId, address }, err instanceof Error ? err : undefined);
|
|
463
|
+
}
|
|
464
|
+
let code;
|
|
465
|
+
try {
|
|
466
|
+
code = await this.getProvider().getCode(address);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
throw this.rpcError(`Failed to read code for ${address}`, err, { address });
|
|
470
|
+
}
|
|
471
|
+
if (!code.startsWith('0x'))
|
|
472
|
+
return null;
|
|
473
|
+
const stripped = code.slice(2).toLowerCase();
|
|
474
|
+
if (stripped.length !== 46)
|
|
475
|
+
return null;
|
|
476
|
+
if (!stripped.startsWith('ef0100'))
|
|
477
|
+
return null;
|
|
478
|
+
return { delegate: getAddress(`0x${stripped.slice(6)}`) };
|
|
479
|
+
}
|
|
480
|
+
async call(req) {
|
|
481
|
+
if (!this.validateAddress(req.to)) {
|
|
482
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM 'to' address for call: ${req.to}`, { chainId: this.chainId, address: req.to });
|
|
483
|
+
}
|
|
484
|
+
if (req.from !== undefined && !this.validateAddress(req.from)) {
|
|
485
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM 'from' address for call: ${req.from}`, { chainId: this.chainId, address: req.from });
|
|
486
|
+
}
|
|
487
|
+
if (!/^0x([0-9a-fA-F]{2})*$/.test(req.data)) {
|
|
488
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `EvmCallRequest.data must be 0x-prefixed even-length hex`, { chainId: this.chainId });
|
|
489
|
+
}
|
|
490
|
+
const provider = this.getProvider();
|
|
491
|
+
const tx = {
|
|
492
|
+
to: this.normalizeAddressOrThrow(req.to, 'call.to'),
|
|
493
|
+
data: req.data,
|
|
494
|
+
from: req.from === undefined ? undefined : this.normalizeAddressOrThrow(req.from, 'call.from'),
|
|
495
|
+
value: req.value,
|
|
496
|
+
};
|
|
497
|
+
if (req.estimateGas && req.blockTag !== undefined) {
|
|
498
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `EvmChain.call: blockTag is not honored when estimateGas: true (ethers v6 eth_estimateGas has no block-tag plumbing). Split the call: use estimateGas without blockTag, or drop estimateGas to run eth_call at the tag.`, { chainId: this.chainId });
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
if (req.estimateGas) {
|
|
502
|
+
const g = await provider.estimateGas(tx);
|
|
503
|
+
return { gasEstimate: BigInt(g) };
|
|
504
|
+
}
|
|
505
|
+
const result = await provider.call(req.blockTag !== undefined ? { ...tx, blockTag: req.blockTag } : tx);
|
|
506
|
+
return { result };
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
const strCode = ethersErrCode(err);
|
|
510
|
+
const rawData = err.data
|
|
511
|
+
?? err.info?.error?.data;
|
|
512
|
+
const revertData = typeof rawData === 'string' && /^0x[0-9a-fA-F]*$/.test(rawData) ? rawData : undefined;
|
|
513
|
+
if (strCode === 'CALL_EXCEPTION' || revertData !== undefined) {
|
|
514
|
+
throw new ChainError(ChainErrorKinds.SimulationFailed, sanitizeMessage(`eth_${req.estimateGas ? 'estimateGas' : 'call'} on ${req.to} reverted`, this._resolvedRpcUrl), { chainId: this.chainId, revertData }, sanitizeCause(err, this._resolvedRpcUrl));
|
|
515
|
+
}
|
|
516
|
+
if (strCode === 'INSUFFICIENT_FUNDS') {
|
|
517
|
+
throw new ChainError(ChainErrorKinds.InsufficientFunds, sanitizeMessage(`eth_${req.estimateGas ? 'estimateGas' : 'call'} on ${req.to}: insufficient funds`, this._resolvedRpcUrl), { chainId: this.chainId }, sanitizeCause(err, this._resolvedRpcUrl));
|
|
518
|
+
}
|
|
519
|
+
throw this.rpcError(`eth_${req.estimateGas ? 'estimateGas' : 'call'} on ${req.to} failed`, err);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
buildAuthorizationDigest(input) {
|
|
523
|
+
if (!this.supports7702) {
|
|
524
|
+
throw new ChainError(ChainErrorKinds.FeatureNotSupported, `EVM chain ${this.name} was not initialized with supports7702: true`, { chainId: this.chainId });
|
|
525
|
+
}
|
|
526
|
+
if (input.chainId !== this.chainId) {
|
|
527
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization chainId ${input.chainId} does not match ${this.chainId}. Cross-chain replay guard: chainId=0 wildcards are rejected by default.`, { chainId: this.chainId });
|
|
528
|
+
}
|
|
529
|
+
if (input.nonce < 0n) {
|
|
530
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization nonce must be >= 0`, { chainId: this.chainId });
|
|
531
|
+
}
|
|
532
|
+
let normalizedDelegate;
|
|
533
|
+
try {
|
|
534
|
+
normalizedDelegate = getAddress(input.delegate);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `buildAuthorizationDigest: delegate is not a valid EVM address: ${input.delegate}`, { chainId: this.chainId, address: input.delegate }, err instanceof Error ? err : undefined);
|
|
538
|
+
}
|
|
539
|
+
const rlp = encodeRlp([
|
|
540
|
+
toBeArray(BigInt(input.chainId)),
|
|
541
|
+
normalizedDelegate,
|
|
542
|
+
toBeArray(input.nonce),
|
|
543
|
+
]);
|
|
544
|
+
const payload = concat(['0x05', rlp]);
|
|
545
|
+
return getBytes(keccak256(payload));
|
|
546
|
+
}
|
|
547
|
+
async createUnsignedTransaction(req) {
|
|
548
|
+
if (req && req.signal !== undefined) {
|
|
549
|
+
throw new ChainError(ChainErrorKinds.FeatureNotSupported, `EVM createUnsignedTransaction: signal is not honored in 0.3.0 (silently ignoring would let a caller conclude 'not built' while the blockhash fetch/build proceeds against stale state). Cancellation returns in 0.3.1.`, { chainId: this.chainId });
|
|
550
|
+
}
|
|
551
|
+
if (!req.from) {
|
|
552
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, 'CreateEvmUnsignedTransactionRequest.from is required', { chainId: this.chainId });
|
|
553
|
+
}
|
|
554
|
+
if (!this.validateAddress(req.from)) {
|
|
555
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM 'from' address (fails EIP-55 checksum or byte length): ${req.from}`, { chainId: this.chainId, address: req.from });
|
|
556
|
+
}
|
|
557
|
+
if (!req.to) {
|
|
558
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, 'CreateEvmUnsignedTransactionRequest.to is required', { chainId: this.chainId });
|
|
559
|
+
}
|
|
560
|
+
if (!this.validateAddress(req.to)) {
|
|
561
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Invalid EVM 'to' address (fails EIP-55 checksum or byte length): ${req.to}`, { chainId: this.chainId, address: req.to });
|
|
562
|
+
}
|
|
563
|
+
if (req.data !== undefined && !/^0x([0-9a-fA-F]{2})*$/.test(req.data)) {
|
|
564
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `CreateEvmUnsignedTransactionRequest.data must be a 0x-prefixed even-length hex string`, { chainId: this.chainId });
|
|
565
|
+
}
|
|
566
|
+
if (req.authorizationList !== undefined) {
|
|
567
|
+
if (!Array.isArray(req.authorizationList) || req.authorizationList.length === 0) {
|
|
568
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, 'authorizationList must be a non-empty array when provided', { chainId: this.chainId });
|
|
569
|
+
}
|
|
570
|
+
if (!this.supports7702) {
|
|
571
|
+
throw new ChainError(ChainErrorKinds.FeatureNotSupported, `EVM chain ${this.name} was not initialized with supports7702: true`, { chainId: this.chainId });
|
|
572
|
+
}
|
|
573
|
+
for (let i = 0; i < req.authorizationList.length; i++) {
|
|
574
|
+
const auth = req.authorizationList[i];
|
|
575
|
+
if (auth.chainId !== this.chainId) {
|
|
576
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization[${i}].chainId ${auth.chainId} does not match ${this.chainId}. Cross-chain replay guard: chainId=0 wildcards are rejected by default.`, { chainId: this.chainId });
|
|
577
|
+
}
|
|
578
|
+
if (!this.validateAddress(auth.address)) {
|
|
579
|
+
throw new ChainError(ChainErrorKinds.InvalidAddress, `Authorization[${i}].address is not a valid EVM address: ${auth.address}`, { chainId: this.chainId, address: auth.address });
|
|
580
|
+
}
|
|
581
|
+
if (typeof auth.nonce !== 'bigint' || auth.nonce < 0n) {
|
|
582
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization[${i}].nonce must be a non-negative bigint (got ${typeof auth.nonce})`, { chainId: this.chainId });
|
|
583
|
+
}
|
|
584
|
+
const sig = auth.signature;
|
|
585
|
+
if (!sig || typeof sig !== 'object'
|
|
586
|
+
|| typeof sig.r !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(sig.r)
|
|
587
|
+
|| typeof sig.s !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(sig.s)
|
|
588
|
+
|| (sig.yParity !== 0 && sig.yParity !== 1)) {
|
|
589
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization[${i}].signature must be { r: 0x + 64-hex, s: 0x + 64-hex, yParity: 0 | 1 }`, { chainId: this.chainId });
|
|
590
|
+
}
|
|
591
|
+
const rBig = BigInt(sig.r);
|
|
592
|
+
const sBig = BigInt(sig.s);
|
|
593
|
+
if (rBig === 0n || sBig === 0n) {
|
|
594
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization[${i}].signature: r and s must both be non-zero`, { chainId: this.chainId });
|
|
595
|
+
}
|
|
596
|
+
// EIP-2 low-s canonical form. An authorization with s > secp256k1n/2
|
|
597
|
+
// is silently rejected by the EVM at execution — tx lands, gas is
|
|
598
|
+
// spent, no delegation installed. Catch at build time.
|
|
599
|
+
const SECP256K1_HALF_N = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n;
|
|
600
|
+
if (sBig > SECP256K1_HALF_N) {
|
|
601
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `Authorization[${i}].signature.s violates EIP-2 low-s canonical form (s must be <= secp256k1n/2). Non-canonical authorizations are silently skipped by the EVM.`, { chainId: this.chainId });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!this.supportsEip1559 && (req.maxFeePerGas !== undefined || req.maxPriorityFeePerGas !== undefined)) {
|
|
606
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `EVM chain ${this.name} is legacy (supportsEip1559=false); maxFeePerGas/maxPriorityFeePerGas are not accepted. Use gasPrice via UnsignedEvmTransaction consumers.`, { chainId: this.chainId });
|
|
607
|
+
}
|
|
608
|
+
const emittedType = req.authorizationList !== undefined ? 4 : this.supportsEip1559 ? 2 : 0;
|
|
609
|
+
return new UnsignedEvmTransaction({
|
|
610
|
+
chainId: this.chainId,
|
|
611
|
+
from: this.normalizeAddressOrThrow(req.from, 'createUnsignedTransaction.from'),
|
|
612
|
+
to: this.normalizeAddressOrThrow(req.to, 'createUnsignedTransaction.to'),
|
|
613
|
+
value: req.value ?? 0n,
|
|
614
|
+
data: req.data ?? '0x',
|
|
615
|
+
type: emittedType,
|
|
616
|
+
authorizationList: req.authorizationList,
|
|
617
|
+
gasLimit: req.gasLimit,
|
|
618
|
+
nonce: req.nonce,
|
|
619
|
+
maxFeePerGas: req.maxFeePerGas,
|
|
620
|
+
maxPriorityFeePerGas: req.maxPriorityFeePerGas,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
classifyBroadcastError(err) {
|
|
624
|
+
const rpc = this._resolvedRpcUrl;
|
|
625
|
+
const strCode = ethersErrCode(err);
|
|
626
|
+
const numCode = errCode(err);
|
|
627
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
628
|
+
const msg = rawMsg.toLowerCase();
|
|
629
|
+
const safeCause = sanitizeCause(err, rpc);
|
|
630
|
+
if (strCode === 'NONCE_EXPIRED' || msg.includes('nonce too low') || msg.includes('nonce_too_low') || msg.includes('nonce has already been used')) {
|
|
631
|
+
return new ChainError(ChainErrorKinds.NonceTooLow, sanitizeMessage(`EVM broadcast rejected: nonce too low on ${this.name}`, rpc), { chainId: this.chainId, rpcHost: this.rpcHost() }, safeCause);
|
|
632
|
+
}
|
|
633
|
+
if (strCode === 'INSUFFICIENT_FUNDS' || msg.includes('insufficient funds')) {
|
|
634
|
+
return new ChainError(ChainErrorKinds.InsufficientFunds, sanitizeMessage(`EVM broadcast rejected: insufficient funds on ${this.name}`, rpc), { chainId: this.chainId, rpcHost: this.rpcHost() }, safeCause);
|
|
635
|
+
}
|
|
636
|
+
if (strCode === 'REPLACEMENT_UNDERPRICED' || msg.includes('replacement') || msg.includes('underpriced')) {
|
|
637
|
+
return new ChainError(ChainErrorKinds.BroadcastRejected, sanitizeMessage(`EVM broadcast rejected on ${this.name} (replacement/known-nonce)`, rpc), { chainId: this.chainId, rpcHost: this.rpcHost() }, safeCause);
|
|
638
|
+
}
|
|
639
|
+
const rateLimitCodes = new Set([-32005, -32007, -32016, -32029, 429]);
|
|
640
|
+
if (numCode !== undefined && rateLimitCodes.has(numCode)) {
|
|
641
|
+
return this.rpcError(`EVM broadcast rate-limited on ${this.name}`, err);
|
|
642
|
+
}
|
|
643
|
+
const transportSignals = /econnreset|econnrefused|econnaborted|etimedout|enotfound|network request failed|fetch failed|socket hang up|too\s+many\s+requests|rate.?limit|network error/;
|
|
644
|
+
const httpStatus = err.info?.status
|
|
645
|
+
?? err.status;
|
|
646
|
+
const isTransportHttp = typeof httpStatus === 'number' && (httpStatus === 429 || httpStatus === 502 || httpStatus === 503 || httpStatus === 504);
|
|
647
|
+
if (transportSignals.test(msg) || isTransportHttp
|
|
648
|
+
|| strCode === 'NETWORK_ERROR' || strCode === 'SERVER_ERROR' || strCode === 'TIMEOUT') {
|
|
649
|
+
return this.rpcError(`EVM broadcast transport failure on ${this.name}`, err);
|
|
650
|
+
}
|
|
651
|
+
// Default terminal: any unrecognized rejection is a node reject, NOT a
|
|
652
|
+
// transient transport failure. This matches Solana/UTXO defaults and
|
|
653
|
+
// prevents a relayer retry-loop from re-broadcasting a permanently-
|
|
654
|
+
// invalid tx forever.
|
|
655
|
+
return new ChainError(ChainErrorKinds.BroadcastRejected, sanitizeMessage(`EVM broadcast rejected on ${this.name}`, rpc), { chainId: this.chainId, rpcHost: this.rpcHost() }, safeCause);
|
|
656
|
+
}
|
|
657
|
+
async getTransactionStatus(txHash, opts) {
|
|
658
|
+
if (Array.isArray(txHash)) {
|
|
659
|
+
return runBatchStatus(txHash, (h, batchSignal) => {
|
|
660
|
+
const composedSignal = opts?.signal
|
|
661
|
+
? anySignal(opts.signal, batchSignal)
|
|
662
|
+
: batchSignal;
|
|
663
|
+
return this.getSingleTransactionStatus(h, { ...opts, signal: composedSignal });
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return this.getSingleTransactionStatus(txHash, opts);
|
|
667
|
+
}
|
|
668
|
+
async getSingleTransactionStatus(txHash, opts) {
|
|
669
|
+
if (opts?.confirmations !== undefined && opts.confirmations > 1 && !opts.wait) {
|
|
670
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `getTransactionStatus: confirmations > 1 requires wait: true (a single status read cannot enforce block depth)`, { chainId: this.chainId, txHash });
|
|
671
|
+
}
|
|
672
|
+
if (!opts?.wait)
|
|
673
|
+
return this.getTransactionStatusOnce(txHash);
|
|
674
|
+
if (opts.signal?.aborted) {
|
|
675
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `getTransactionStatus aborted before first poll`, { chainId: this.chainId, txHash });
|
|
676
|
+
}
|
|
677
|
+
if (opts.timeoutMs === undefined) {
|
|
678
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `getTransactionStatus: wait: true requires an explicit timeoutMs. Unbounded polling would pin an RPC worker + connection forever if the tx is dropped.`, { chainId: this.chainId, txHash });
|
|
679
|
+
}
|
|
680
|
+
if (opts.timeoutMs < 0) {
|
|
681
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `getTransactionStatus: timeoutMs must be >= 0`, { chainId: this.chainId, txHash });
|
|
682
|
+
}
|
|
683
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
684
|
+
const pollMs = Math.max(500, this.blockTimeSeconds * 1000);
|
|
685
|
+
const minConfirmations = Math.max(1, opts.confirmations ?? 1);
|
|
686
|
+
let last;
|
|
687
|
+
while (true) {
|
|
688
|
+
last = await this.getTransactionStatusOnce(txHash);
|
|
689
|
+
if (last.status === 'Success' || last.status === 'Failed') {
|
|
690
|
+
if (minConfirmations <= 1)
|
|
691
|
+
return last;
|
|
692
|
+
let tip;
|
|
693
|
+
try {
|
|
694
|
+
tip = await this.getProvider().getBlockNumber();
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
throw this.rpcError(`Failed to read chain tip for confirmations check`, err, { txHash });
|
|
698
|
+
}
|
|
699
|
+
const depth = last.blockNumber === null ? 0 : tip - last.blockNumber + 1;
|
|
700
|
+
if (depth >= minConfirmations)
|
|
701
|
+
return last;
|
|
702
|
+
if (Date.now() >= deadline) {
|
|
703
|
+
throw new ChainError(ChainErrorKinds.RpcError, `getTransactionStatus timed out after ${opts.timeoutMs}ms with only ${depth} confirmation(s); required ${minConfirmations}. Consumer must NOT credit as final.`, { chainId: this.chainId, txHash });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else if (Date.now() >= deadline) {
|
|
707
|
+
throw new ChainError(ChainErrorKinds.RpcError, `getTransactionStatus timed out after ${opts.timeoutMs}ms; last observed status was ${last.status}. Consumer must NOT credit as final AND must NOT re-sign — the tx may still land. Re-broadcast the same signed bytes or continue polling with the same txHash.`, { chainId: this.chainId, txHash });
|
|
708
|
+
}
|
|
709
|
+
await interruptibleSleep(pollMs, opts.signal);
|
|
710
|
+
if (opts.signal?.aborted) {
|
|
711
|
+
throw new ChainError(ChainErrorKinds.InvalidArgument, `getTransactionStatus aborted mid-poll`, { chainId: this.chainId, txHash });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async getTransactionStatusOnce(txHash) {
|
|
377
716
|
const provider = this.getProvider();
|
|
378
717
|
let tx = null;
|
|
379
718
|
let receipt = null;
|
|
@@ -469,6 +808,7 @@ export class EvmChain extends Chain {
|
|
|
469
808
|
inclusionAt,
|
|
470
809
|
error: errorInfo,
|
|
471
810
|
fees,
|
|
811
|
+
blockNumber: receipt.blockNumber,
|
|
472
812
|
});
|
|
473
813
|
}
|
|
474
814
|
// Only allocated on the Success path — failed txs carry logs: null.
|
|
@@ -495,6 +835,7 @@ export class EvmChain extends Chain {
|
|
|
495
835
|
balanceChanges,
|
|
496
836
|
logs,
|
|
497
837
|
fees,
|
|
838
|
+
blockNumber: receipt.blockNumber,
|
|
498
839
|
});
|
|
499
840
|
}
|
|
500
841
|
async decodeBalanceChanges(args) {
|
|
@@ -678,6 +1019,84 @@ function stringifyErr(err) {
|
|
|
678
1019
|
return err.message;
|
|
679
1020
|
return String(err);
|
|
680
1021
|
}
|
|
1022
|
+
const BATCH_STATUS_CONCURRENCY = 8;
|
|
1023
|
+
async function runBatchStatus(items, fetchOne) {
|
|
1024
|
+
const results = new Array(items.length);
|
|
1025
|
+
const ac = new AbortController();
|
|
1026
|
+
let cursor = 0;
|
|
1027
|
+
const workers = [];
|
|
1028
|
+
const spawn = async () => {
|
|
1029
|
+
while (!ac.signal.aborted) {
|
|
1030
|
+
const idx = cursor++;
|
|
1031
|
+
if (idx >= items.length)
|
|
1032
|
+
return;
|
|
1033
|
+
try {
|
|
1034
|
+
results[idx] = await fetchOne(items[idx], ac.signal);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
ac.abort();
|
|
1038
|
+
throw err;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
const workerCount = Math.min(BATCH_STATUS_CONCURRENCY, items.length);
|
|
1043
|
+
for (let i = 0; i < workerCount; i++)
|
|
1044
|
+
workers.push(spawn());
|
|
1045
|
+
await Promise.all(workers);
|
|
1046
|
+
return results;
|
|
1047
|
+
}
|
|
1048
|
+
function anySignal(a, b) {
|
|
1049
|
+
if (typeof AbortSignal.any === 'function')
|
|
1050
|
+
return AbortSignal.any([a, b]);
|
|
1051
|
+
const ac = new AbortController();
|
|
1052
|
+
const onAbort = () => ac.abort();
|
|
1053
|
+
if (a.aborted || b.aborted)
|
|
1054
|
+
ac.abort();
|
|
1055
|
+
else {
|
|
1056
|
+
a.addEventListener('abort', onAbort, { once: true });
|
|
1057
|
+
b.addEventListener('abort', onAbort, { once: true });
|
|
1058
|
+
}
|
|
1059
|
+
return ac.signal;
|
|
1060
|
+
}
|
|
1061
|
+
async function interruptibleSleep(ms, signal) {
|
|
1062
|
+
if (signal?.aborted)
|
|
1063
|
+
return;
|
|
1064
|
+
await new Promise((resolve) => {
|
|
1065
|
+
const t = setTimeout(() => {
|
|
1066
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1067
|
+
resolve();
|
|
1068
|
+
}, ms);
|
|
1069
|
+
const onAbort = () => {
|
|
1070
|
+
clearTimeout(t);
|
|
1071
|
+
resolve();
|
|
1072
|
+
};
|
|
1073
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
function errCode(err) {
|
|
1077
|
+
if (typeof err !== 'object' || err === null)
|
|
1078
|
+
return undefined;
|
|
1079
|
+
const e = err;
|
|
1080
|
+
const candidates = [
|
|
1081
|
+
typeof e.code === 'number' ? e.code : undefined,
|
|
1082
|
+
e.error?.code,
|
|
1083
|
+
e.info?.error?.code,
|
|
1084
|
+
typeof e.code === 'string' && /^-?\d+$/.test(e.code) ? e.code : undefined,
|
|
1085
|
+
];
|
|
1086
|
+
for (const raw of candidates) {
|
|
1087
|
+
if (typeof raw === 'number')
|
|
1088
|
+
return raw;
|
|
1089
|
+
if (typeof raw === 'string' && /^-?\d+$/.test(raw))
|
|
1090
|
+
return Number(raw);
|
|
1091
|
+
}
|
|
1092
|
+
return undefined;
|
|
1093
|
+
}
|
|
1094
|
+
function ethersErrCode(err) {
|
|
1095
|
+
if (typeof err !== 'object' || err === null)
|
|
1096
|
+
return undefined;
|
|
1097
|
+
const e = err;
|
|
1098
|
+
return typeof e.code === 'string' ? e.code : undefined;
|
|
1099
|
+
}
|
|
681
1100
|
function envCandidatesFor(name, chainId) {
|
|
682
1101
|
const normalized = name.replace(/ /g, '_').toUpperCase() + '_RPC_URL';
|
|
683
1102
|
return [normalized, `EVM_${chainId}_RPC_URL`];
|