@persistenceone/bridgekitty 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/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/backends/across.d.ts +10 -0
- package/dist/backends/across.js +285 -0
- package/dist/backends/debridge.d.ts +11 -0
- package/dist/backends/debridge.js +380 -0
- package/dist/backends/lifi.d.ts +19 -0
- package/dist/backends/lifi.js +295 -0
- package/dist/backends/persistence.d.ts +86 -0
- package/dist/backends/persistence.js +642 -0
- package/dist/backends/relay.d.ts +11 -0
- package/dist/backends/relay.js +292 -0
- package/dist/backends/squid.d.ts +31 -0
- package/dist/backends/squid.js +476 -0
- package/dist/backends/types.d.ts +125 -0
- package/dist/backends/types.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +154 -0
- package/dist/routing/engine.d.ts +49 -0
- package/dist/routing/engine.js +336 -0
- package/dist/tools/check-status.d.ts +3 -0
- package/dist/tools/check-status.js +93 -0
- package/dist/tools/execute-bridge.d.ts +3 -0
- package/dist/tools/execute-bridge.js +428 -0
- package/dist/tools/get-chains.d.ts +3 -0
- package/dist/tools/get-chains.js +162 -0
- package/dist/tools/get-quote.d.ts +3 -0
- package/dist/tools/get-quote.js +534 -0
- package/dist/tools/get-tokens.d.ts +3 -0
- package/dist/tools/get-tokens.js +128 -0
- package/dist/tools/help.d.ts +2 -0
- package/dist/tools/help.js +204 -0
- package/dist/tools/multi-quote.d.ts +3 -0
- package/dist/tools/multi-quote.js +310 -0
- package/dist/tools/onboard.d.ts +3 -0
- package/dist/tools/onboard.js +218 -0
- package/dist/tools/wallet.d.ts +14 -0
- package/dist/tools/wallet.js +744 -0
- package/dist/tools/xprt-farm.d.ts +3 -0
- package/dist/tools/xprt-farm.js +1308 -0
- package/dist/tools/xprt-rewards.d.ts +2 -0
- package/dist/tools/xprt-rewards.js +177 -0
- package/dist/tools/xprt-staking.d.ts +2 -0
- package/dist/tools/xprt-staking.js +565 -0
- package/dist/utils/chains.d.ts +22 -0
- package/dist/utils/chains.js +154 -0
- package/dist/utils/circuit-breaker.d.ts +64 -0
- package/dist/utils/circuit-breaker.js +160 -0
- package/dist/utils/evm.d.ts +18 -0
- package/dist/utils/evm.js +46 -0
- package/dist/utils/fill-detector.d.ts +70 -0
- package/dist/utils/fill-detector.js +298 -0
- package/dist/utils/gas-estimator.d.ts +67 -0
- package/dist/utils/gas-estimator.js +340 -0
- package/dist/utils/sanitize-error.d.ts +23 -0
- package/dist/utils/sanitize-error.js +101 -0
- package/dist/utils/token-registry.d.ts +70 -0
- package/dist/utils/token-registry.js +669 -0
- package/dist/utils/tokens.d.ts +17 -0
- package/dist/utils/tokens.js +37 -0
- package/dist/utils/tx-simulator.d.ts +27 -0
- package/dist/utils/tx-simulator.js +105 -0
- package/package.json +75 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Synthetic chain IDs for non-EVM chains.
|
|
2
|
+
// These are high positive integers that won't collide with real EVM chain IDs
|
|
3
|
+
// but satisfy the routing engine's requirement for positive integer chain IDs.
|
|
4
|
+
export const PERSISTENCE_CHAIN_ID = 9999001;
|
|
5
|
+
export const COSMOSHUB_CHAIN_ID = 9999002;
|
|
6
|
+
// Solana uses its real chain ID (as recognized by deBridge)
|
|
7
|
+
export const SOLANA_CHAIN_ID = 7565164;
|
|
8
|
+
const CHAINS = [
|
|
9
|
+
{ id: 1, name: "Ethereum", key: "ethereum" },
|
|
10
|
+
{ id: 10, name: "Optimism", key: "optimism" },
|
|
11
|
+
{ id: 25, name: "Cronos", key: "cronos" },
|
|
12
|
+
{ id: 56, name: "BNB Chain", key: "bsc" },
|
|
13
|
+
{ id: 100, name: "Gnosis", key: "gnosis" },
|
|
14
|
+
{ id: 122, name: "Fuse", key: "fuse" },
|
|
15
|
+
{ id: 130, name: "Unichain", key: "unichain" },
|
|
16
|
+
{ id: 137, name: "Polygon", key: "polygon" },
|
|
17
|
+
{ id: 143, name: "Monad", key: "monad" },
|
|
18
|
+
{ id: 196, name: "X Layer", key: "xlayer" },
|
|
19
|
+
{ id: 250, name: "Fantom", key: "fantom" },
|
|
20
|
+
{ id: 252, name: "Fraxtal", key: "fraxtal" },
|
|
21
|
+
{ id: 288, name: "Boba", key: "boba" },
|
|
22
|
+
{ id: 324, name: "zkSync Era", key: "zksync" },
|
|
23
|
+
{ id: 480, name: "World Chain", key: "world-chain" },
|
|
24
|
+
{ id: 690, name: "Redstone", key: "redstone" },
|
|
25
|
+
{ id: 1088, name: "Metis", key: "metis" },
|
|
26
|
+
{ id: 1101, name: "Polygon zkEVM", key: "polygon-zkevm" },
|
|
27
|
+
{ id: 1135, name: "Lisk", key: "lisk" },
|
|
28
|
+
{ id: 1284, name: "Moonbeam", key: "moonbeam" },
|
|
29
|
+
{ id: 1329, name: "Sei", key: "sei" },
|
|
30
|
+
{ id: 1625, name: "Gravity", key: "gravity" },
|
|
31
|
+
{ id: 1868, name: "Soneium", key: "soneium" },
|
|
32
|
+
{ id: 1923, name: "Swellchain", key: "swellchain" },
|
|
33
|
+
{ id: 2741, name: "Abstract", key: "abstract" },
|
|
34
|
+
{ id: 5000, name: "Mantle", key: "mantle" },
|
|
35
|
+
{ id: 7560, name: "Cyber", key: "cyber" },
|
|
36
|
+
{ id: 7777777, name: "Zora", key: "zora" },
|
|
37
|
+
{ id: 8453, name: "Base", key: "base" },
|
|
38
|
+
{ id: 13371, name: "Immutable zkEVM", key: "immutable-zkevm" },
|
|
39
|
+
{ id: 17000, name: "Holesky", key: "holesky" },
|
|
40
|
+
{ id: 30, name: "Rootstock", key: "rootstock" },
|
|
41
|
+
{ id: 33139, name: "ApeChain", key: "apechain" },
|
|
42
|
+
{ id: 34443, name: "Mode", key: "mode" },
|
|
43
|
+
{ id: 42161, name: "Arbitrum", key: "arbitrum" },
|
|
44
|
+
{ id: 42170, name: "Arbitrum Nova", key: "arbitrum-nova" },
|
|
45
|
+
{ id: 42220, name: "Celo", key: "celo" },
|
|
46
|
+
{ id: 43114, name: "Avalanche", key: "avalanche" },
|
|
47
|
+
{ id: 44787, name: "Celo Alfajores", key: "celo-alfajores" },
|
|
48
|
+
{ id: 57073, name: "Ink", key: "ink" },
|
|
49
|
+
{ id: 59144, name: "Linea", key: "linea" },
|
|
50
|
+
{ id: 60808, name: "Bob", key: "bob" },
|
|
51
|
+
{ id: 81457, name: "Blast", key: "blast" },
|
|
52
|
+
{ id: 146, name: "Sonic", key: "sonic" },
|
|
53
|
+
{ id: 167000, name: "Taiko", key: "taiko" },
|
|
54
|
+
{ id: 2020, name: "Ronin", key: "ronin" },
|
|
55
|
+
{ id: 204, name: "opBNB", key: "opbnb" },
|
|
56
|
+
{ id: 534352, name: "Scroll", key: "scroll" },
|
|
57
|
+
{ id: 7225878, name: "Saakuru", key: "saakuru" },
|
|
58
|
+
{ id: 666666666, name: "Degen", key: "degen" },
|
|
59
|
+
{ id: 80094, name: "Berachain", key: "berachain" },
|
|
60
|
+
{ id: 50104, name: "Sophon", key: "sophon" },
|
|
61
|
+
{ id: 37714555429, name: "Xai", key: "xai" },
|
|
62
|
+
{ id: 14, name: "Flare", key: "flare" },
|
|
63
|
+
{ id: 1516, name: "Story", key: "story" },
|
|
64
|
+
{ id: 4801, name: "World Chain Testnet", key: "world-chain-testnet" },
|
|
65
|
+
{ id: 8217, name: "Kaia", key: "kaia" },
|
|
66
|
+
{ id: 1750, name: "Metal L2", key: "metal" },
|
|
67
|
+
{ id: 2522, name: "Shadow", key: "shadow" },
|
|
68
|
+
{ id: 98865, name: "Plume", key: "plume" },
|
|
69
|
+
{ id: 21000000, name: "Corn", key: "corn" },
|
|
70
|
+
{ id: 232, name: "Lens", key: "lens" },
|
|
71
|
+
{ id: 999, name: "HyperEVM", key: "hyperevm" },
|
|
72
|
+
{ id: 360, name: "Shape", key: "shape" },
|
|
73
|
+
{ id: 1514, name: "Hemi", key: "hemi" },
|
|
74
|
+
{ id: 810180, name: "zkLink Nova", key: "zklink-nova" },
|
|
75
|
+
{ id: 4326, name: "MegaETH", key: "megaeth" },
|
|
76
|
+
{ id: 9745, name: "Plasma", key: "plasma" },
|
|
77
|
+
// Cosmos chains (synthetic IDs — mapped to real chain ID strings by Squid backend)
|
|
78
|
+
{ id: PERSISTENCE_CHAIN_ID, name: "Persistence", key: "persistence" },
|
|
79
|
+
{ id: COSMOSHUB_CHAIN_ID, name: "Cosmos Hub", key: "cosmoshub" },
|
|
80
|
+
// Solana (uses real chain ID as recognized by deBridge)
|
|
81
|
+
{ id: SOLANA_CHAIN_ID, name: "Solana", key: "solana" },
|
|
82
|
+
];
|
|
83
|
+
// Backend-specific chain ID overrides (reserved for future non-EVM chain support)
|
|
84
|
+
const CHAIN_ID_OVERRIDES = {};
|
|
85
|
+
export function getBackendChainId(backendName, chainId) {
|
|
86
|
+
const key = backendName.toLowerCase().replace(/\s*\(.*\)/, "");
|
|
87
|
+
return CHAIN_ID_OVERRIDES[key]?.[chainId] ?? chainId;
|
|
88
|
+
}
|
|
89
|
+
// Mapping from chain key → real Cosmos chain ID string (for Squid Router / IBC)
|
|
90
|
+
export const COSMOS_CHAIN_IDS = {
|
|
91
|
+
persistence: "core-1",
|
|
92
|
+
cosmoshub: "cosmoshub-4",
|
|
93
|
+
};
|
|
94
|
+
// Reverse mapping: synthetic numeric ID → Cosmos chain ID string (as used by Squid Router)
|
|
95
|
+
export const SYNTHETIC_TO_COSMOS = {
|
|
96
|
+
[PERSISTENCE_CHAIN_ID]: "core-1",
|
|
97
|
+
[COSMOSHUB_CHAIN_ID]: "cosmoshub-4",
|
|
98
|
+
};
|
|
99
|
+
export function resolveChainId(input) {
|
|
100
|
+
// Try numeric: only accept chain IDs that are in the known CHAINS array (V3-LOW-003)
|
|
101
|
+
const num = Number(input);
|
|
102
|
+
if (!isNaN(num) && Number.isInteger(num) && num > 0) {
|
|
103
|
+
const known = CHAINS.find((c) => c.id === num);
|
|
104
|
+
return known ? known.id : null;
|
|
105
|
+
}
|
|
106
|
+
// Also accept Cosmos chain ID strings directly (e.g. "core-1", "cosmoshub-4")
|
|
107
|
+
const lower = input.toLowerCase().trim();
|
|
108
|
+
for (const [syntheticId, cosmosId] of Object.entries(SYNTHETIC_TO_COSMOS)) {
|
|
109
|
+
if (lower === cosmosId || lower === cosmosId.replace(/-/g, "")) {
|
|
110
|
+
return Number(syntheticId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Also accept common aliases for Cosmos chains
|
|
114
|
+
if (lower === "persistence-core-1" || lower === "persistencecore1") {
|
|
115
|
+
return PERSISTENCE_CHAIN_ID;
|
|
116
|
+
}
|
|
117
|
+
if (lower === "cosmoshub4") {
|
|
118
|
+
return COSMOSHUB_CHAIN_ID;
|
|
119
|
+
}
|
|
120
|
+
// Solana aliases
|
|
121
|
+
if (lower === "sol" || lower === "solana") {
|
|
122
|
+
return SOLANA_CHAIN_ID;
|
|
123
|
+
}
|
|
124
|
+
const match = CHAINS.find((c) => c.key === lower || c.name.toLowerCase() === lower);
|
|
125
|
+
return match?.id ?? null;
|
|
126
|
+
}
|
|
127
|
+
/** Check if a chain key or ID refers to Solana */
|
|
128
|
+
export function isSolanaChain(chainKeyOrId) {
|
|
129
|
+
if (typeof chainKeyOrId === "number") {
|
|
130
|
+
return chainKeyOrId === SOLANA_CHAIN_ID;
|
|
131
|
+
}
|
|
132
|
+
return chainKeyOrId.toLowerCase() === "solana" || chainKeyOrId.toLowerCase() === "sol";
|
|
133
|
+
}
|
|
134
|
+
/** Check if a chain key or ID refers to a Cosmos chain */
|
|
135
|
+
export function isCosmosChain(chainKeyOrId) {
|
|
136
|
+
if (typeof chainKeyOrId === "number") {
|
|
137
|
+
return SYNTHETIC_TO_COSMOS[chainKeyOrId] !== undefined;
|
|
138
|
+
}
|
|
139
|
+
return COSMOS_CHAIN_IDS[chainKeyOrId.toLowerCase()] !== undefined;
|
|
140
|
+
}
|
|
141
|
+
/** Get the Cosmos chain ID string for Squid Router from a synthetic numeric ID */
|
|
142
|
+
export function getCosmosChainIdFromSynthetic(syntheticId) {
|
|
143
|
+
return SYNTHETIC_TO_COSMOS[syntheticId] ?? null;
|
|
144
|
+
}
|
|
145
|
+
/** Get the Cosmos chain ID string from a chain key */
|
|
146
|
+
export function getCosmosChainId(key) {
|
|
147
|
+
return COSMOS_CHAIN_IDS[key.toLowerCase()] ?? null;
|
|
148
|
+
}
|
|
149
|
+
export function getChainName(id) {
|
|
150
|
+
return CHAINS.find((c) => c.id === id)?.name ?? `Chain ${id}`;
|
|
151
|
+
}
|
|
152
|
+
export function getAllChains() {
|
|
153
|
+
return CHAINS;
|
|
154
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker for backend failure management.
|
|
3
|
+
*
|
|
4
|
+
* States:
|
|
5
|
+
* - CLOSED: Normal operation, requests pass through
|
|
6
|
+
* - OPEN: Backend is failing, skip requests for a cooldown period
|
|
7
|
+
* - HALF_OPEN: Testing with a single request after cooldown
|
|
8
|
+
*
|
|
9
|
+
* Transitions:
|
|
10
|
+
* - CLOSED → OPEN: After `failureThreshold` failures within `failureWindowMs`
|
|
11
|
+
* - OPEN → HALF_OPEN: After `cooldownMs` elapsed
|
|
12
|
+
* - HALF_OPEN → CLOSED: On success
|
|
13
|
+
* - HALF_OPEN → OPEN: On failure (with extended cooldown)
|
|
14
|
+
*/
|
|
15
|
+
export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
16
|
+
interface CircuitBreakerConfig {
|
|
17
|
+
/** Number of failures before opening the circuit (default: 3) */
|
|
18
|
+
failureThreshold?: number;
|
|
19
|
+
/** Time window for counting failures in ms (default: 300_000 = 5 min) */
|
|
20
|
+
failureWindowMs?: number;
|
|
21
|
+
/** How long to wait before testing again in ms (default: 30_000 = 30s) */
|
|
22
|
+
cooldownMs?: number;
|
|
23
|
+
/** Extended cooldown after HALF_OPEN failure in ms (default: 60_000 = 60s) */
|
|
24
|
+
extendedCooldownMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class CircuitBreaker {
|
|
27
|
+
private circuits;
|
|
28
|
+
private halfOpenLocks;
|
|
29
|
+
private config;
|
|
30
|
+
constructor(config?: CircuitBreakerConfig);
|
|
31
|
+
/**
|
|
32
|
+
* Check if a backend is allowed to receive requests.
|
|
33
|
+
* Returns true if the backend should be called, false if it should be skipped.
|
|
34
|
+
*/
|
|
35
|
+
isAllowed(backendName: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Report a successful request for a backend.
|
|
38
|
+
* Gradual recovery: removes the oldest failure instead of clearing all at once.
|
|
39
|
+
* Full reset only happens from HALF_OPEN state (successful probe).
|
|
40
|
+
*/
|
|
41
|
+
recordSuccess(backendName: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Report a failed request for a backend.
|
|
44
|
+
* May transition to OPEN if threshold is exceeded.
|
|
45
|
+
*/
|
|
46
|
+
recordFailure(backendName: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Get the current state of a backend's circuit.
|
|
49
|
+
*/
|
|
50
|
+
getState(backendName: string): CircuitState;
|
|
51
|
+
/**
|
|
52
|
+
* Get a summary of all circuit states (for debugging/monitoring).
|
|
53
|
+
*/
|
|
54
|
+
getAll(): Record<string, CircuitState>;
|
|
55
|
+
/**
|
|
56
|
+
* Reset a specific backend's circuit (for testing/admin).
|
|
57
|
+
*/
|
|
58
|
+
reset(backendName: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Reset all circuits.
|
|
61
|
+
*/
|
|
62
|
+
resetAll(): void;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker for backend failure management.
|
|
3
|
+
*
|
|
4
|
+
* States:
|
|
5
|
+
* - CLOSED: Normal operation, requests pass through
|
|
6
|
+
* - OPEN: Backend is failing, skip requests for a cooldown period
|
|
7
|
+
* - HALF_OPEN: Testing with a single request after cooldown
|
|
8
|
+
*
|
|
9
|
+
* Transitions:
|
|
10
|
+
* - CLOSED → OPEN: After `failureThreshold` failures within `failureWindowMs`
|
|
11
|
+
* - OPEN → HALF_OPEN: After `cooldownMs` elapsed
|
|
12
|
+
* - HALF_OPEN → CLOSED: On success
|
|
13
|
+
* - HALF_OPEN → OPEN: On failure (with extended cooldown)
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
failureThreshold: 3,
|
|
17
|
+
failureWindowMs: 300_000,
|
|
18
|
+
cooldownMs: 30_000,
|
|
19
|
+
extendedCooldownMs: 60_000,
|
|
20
|
+
};
|
|
21
|
+
export class CircuitBreaker {
|
|
22
|
+
circuits = new Map();
|
|
23
|
+
halfOpenLocks = new Set();
|
|
24
|
+
config;
|
|
25
|
+
constructor(config) {
|
|
26
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check if a backend is allowed to receive requests.
|
|
30
|
+
* Returns true if the backend should be called, false if it should be skipped.
|
|
31
|
+
*/
|
|
32
|
+
isAllowed(backendName) {
|
|
33
|
+
const circuit = this.circuits.get(backendName);
|
|
34
|
+
if (!circuit)
|
|
35
|
+
return true; // No circuit = never failed = allowed
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
switch (circuit.state) {
|
|
38
|
+
case "CLOSED":
|
|
39
|
+
return true;
|
|
40
|
+
case "OPEN": {
|
|
41
|
+
// Check if cooldown has elapsed → transition to HALF_OPEN
|
|
42
|
+
if (now - circuit.openedAt >= circuit.cooldownMs) {
|
|
43
|
+
circuit.state = "HALF_OPEN";
|
|
44
|
+
// Lock so only the first caller gets through (V3-LOW-001)
|
|
45
|
+
if (this.halfOpenLocks.has(backendName))
|
|
46
|
+
return false;
|
|
47
|
+
this.halfOpenLocks.add(backendName);
|
|
48
|
+
return true; // Allow one test request
|
|
49
|
+
}
|
|
50
|
+
return false; // Still in cooldown
|
|
51
|
+
}
|
|
52
|
+
case "HALF_OPEN":
|
|
53
|
+
// Only one concurrent request allowed in HALF_OPEN (V3-LOW-001)
|
|
54
|
+
if (this.halfOpenLocks.has(backendName))
|
|
55
|
+
return false;
|
|
56
|
+
this.halfOpenLocks.add(backendName);
|
|
57
|
+
return true;
|
|
58
|
+
default:
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Report a successful request for a backend.
|
|
64
|
+
* Gradual recovery: removes the oldest failure instead of clearing all at once.
|
|
65
|
+
* Full reset only happens from HALF_OPEN state (successful probe).
|
|
66
|
+
*/
|
|
67
|
+
recordSuccess(backendName) {
|
|
68
|
+
const circuit = this.circuits.get(backendName);
|
|
69
|
+
if (!circuit)
|
|
70
|
+
return;
|
|
71
|
+
if (circuit.state === "HALF_OPEN") {
|
|
72
|
+
// Successful probe — fully reset
|
|
73
|
+
this.halfOpenLocks.delete(backendName);
|
|
74
|
+
console.error(`[circuit-breaker] ${backendName}: HALF_OPEN → CLOSED (probe succeeded)`);
|
|
75
|
+
circuit.state = "CLOSED";
|
|
76
|
+
circuit.failures = [];
|
|
77
|
+
circuit.cooldownMs = this.config.cooldownMs;
|
|
78
|
+
}
|
|
79
|
+
else if (circuit.state === "CLOSED" && circuit.failures.length > 0) {
|
|
80
|
+
// Gradual recovery: remove oldest failure on each success
|
|
81
|
+
circuit.failures.shift();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Report a failed request for a backend.
|
|
86
|
+
* May transition to OPEN if threshold is exceeded.
|
|
87
|
+
*/
|
|
88
|
+
recordFailure(backendName) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
let circuit = this.circuits.get(backendName);
|
|
91
|
+
if (!circuit) {
|
|
92
|
+
circuit = {
|
|
93
|
+
state: "CLOSED",
|
|
94
|
+
failures: [],
|
|
95
|
+
openedAt: 0,
|
|
96
|
+
cooldownMs: this.config.cooldownMs,
|
|
97
|
+
};
|
|
98
|
+
this.circuits.set(backendName, circuit);
|
|
99
|
+
}
|
|
100
|
+
if (circuit.state === "HALF_OPEN") {
|
|
101
|
+
// Test request failed → back to OPEN with extended cooldown
|
|
102
|
+
this.halfOpenLocks.delete(backendName);
|
|
103
|
+
circuit.state = "OPEN";
|
|
104
|
+
circuit.openedAt = now;
|
|
105
|
+
circuit.cooldownMs = this.config.extendedCooldownMs;
|
|
106
|
+
console.error(`[circuit-breaker] ${backendName}: HALF_OPEN → OPEN (probe failed, extended cooldown ${this.config.extendedCooldownMs}ms)`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Add failure timestamp (prune old failures outside the window)
|
|
110
|
+
circuit.failures = circuit.failures
|
|
111
|
+
.filter((t) => now - t < this.config.failureWindowMs)
|
|
112
|
+
.concat(now);
|
|
113
|
+
// Check if threshold exceeded
|
|
114
|
+
if (circuit.failures.length >= this.config.failureThreshold) {
|
|
115
|
+
circuit.state = "OPEN";
|
|
116
|
+
circuit.openedAt = now;
|
|
117
|
+
circuit.cooldownMs = this.config.cooldownMs;
|
|
118
|
+
console.error(`[circuit-breaker] ${backendName}: CLOSED → OPEN (${circuit.failures.length} failures in ${this.config.failureWindowMs}ms window, cooldown ${this.config.cooldownMs}ms)`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get the current state of a backend's circuit.
|
|
123
|
+
*/
|
|
124
|
+
getState(backendName) {
|
|
125
|
+
const circuit = this.circuits.get(backendName);
|
|
126
|
+
if (!circuit)
|
|
127
|
+
return "CLOSED";
|
|
128
|
+
// Check for OPEN → HALF_OPEN transition
|
|
129
|
+
if (circuit.state === "OPEN") {
|
|
130
|
+
if (Date.now() - circuit.openedAt >= circuit.cooldownMs) {
|
|
131
|
+
circuit.state = "HALF_OPEN";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return circuit.state;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get a summary of all circuit states (for debugging/monitoring).
|
|
138
|
+
*/
|
|
139
|
+
getAll() {
|
|
140
|
+
const result = {};
|
|
141
|
+
for (const [name] of this.circuits) {
|
|
142
|
+
result[name] = this.getState(name);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Reset a specific backend's circuit (for testing/admin).
|
|
148
|
+
*/
|
|
149
|
+
reset(backendName) {
|
|
150
|
+
this.circuits.delete(backendName);
|
|
151
|
+
this.halfOpenLocks.delete(backendName);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Reset all circuits.
|
|
155
|
+
*/
|
|
156
|
+
resetAll() {
|
|
157
|
+
this.circuits.clear();
|
|
158
|
+
this.halfOpenLocks.clear();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared EVM utilities used across multiple backends.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Build ERC20 approve(address spender, uint256 amount) calldata.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildApproveData(spender: string, amount: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Check if an address is the native token (zero address).
|
|
10
|
+
*/
|
|
11
|
+
export declare const NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
12
|
+
export declare function isNativeToken(address: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Validate an EVM hex address (0x + 40 hex chars).
|
|
15
|
+
* Returns { valid, warning? } — accepts all valid hex addresses but warns
|
|
16
|
+
* on mixed-case addresses that fail EIP-55 checksum validation (MEDIUM-002).
|
|
17
|
+
*/
|
|
18
|
+
export declare function isValidEvmAddress(address: string): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared EVM utilities used across multiple backends.
|
|
3
|
+
*/
|
|
4
|
+
import { getAddress } from "ethers";
|
|
5
|
+
/**
|
|
6
|
+
* Build ERC20 approve(address spender, uint256 amount) calldata.
|
|
7
|
+
*/
|
|
8
|
+
export function buildApproveData(spender, amount) {
|
|
9
|
+
// ERC20 approve(address,uint256) selector = 0x095ea7b3
|
|
10
|
+
const spenderPadded = spender.toLowerCase().replace("0x", "").padStart(64, "0");
|
|
11
|
+
const amountHex = BigInt(amount).toString(16).padStart(64, "0");
|
|
12
|
+
return `0x095ea7b3${spenderPadded}${amountHex}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check if an address is the native token (zero address).
|
|
16
|
+
*/
|
|
17
|
+
export const NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
18
|
+
export function isNativeToken(address) {
|
|
19
|
+
return (address === NATIVE_ADDRESS ||
|
|
20
|
+
address === "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" ||
|
|
21
|
+
address.toLowerCase() === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validate an EVM hex address (0x + 40 hex chars).
|
|
25
|
+
* Returns { valid, warning? } — accepts all valid hex addresses but warns
|
|
26
|
+
* on mixed-case addresses that fail EIP-55 checksum validation (MEDIUM-002).
|
|
27
|
+
*/
|
|
28
|
+
export function isValidEvmAddress(address) {
|
|
29
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(address))
|
|
30
|
+
return false;
|
|
31
|
+
// If all-lowercase or all-uppercase (after 0x), no checksum to validate
|
|
32
|
+
const body = address.slice(2);
|
|
33
|
+
if (body === body.toLowerCase() || body === body.toUpperCase())
|
|
34
|
+
return true;
|
|
35
|
+
// Mixed-case: validate EIP-55 checksum using ethers
|
|
36
|
+
try {
|
|
37
|
+
const checksummed = getAddress(address.toLowerCase());
|
|
38
|
+
if (checksummed !== address) {
|
|
39
|
+
console.warn(`[evm] MEDIUM-002: Address ${address} has invalid mixed-case (expected EIP-55: ${checksummed}). Accepting but flagging.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
console.warn(`[evm] MEDIUM-002: Could not verify EIP-55 checksum for ${address}. Accepting anyway.`);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-signal fill detector for cross-chain bridge fills.
|
|
3
|
+
*
|
|
4
|
+
* Public RPCs aggressively cache ALL RPC responses (eth_call, eth_getLogs)
|
|
5
|
+
* for 30-120s server-side. WebSocket transport does NOT bypass this.
|
|
6
|
+
*
|
|
7
|
+
* This module provides five detection methods:
|
|
8
|
+
*
|
|
9
|
+
* 1. **eth_subscribe("logs")** — push-based real-time subscription via raw
|
|
10
|
+
* WebSocket. The server pushes matching logs as new blocks are mined.
|
|
11
|
+
* NOT cached because each block is novel. Detects in 3-15s.
|
|
12
|
+
* Uses BROAD filter (token + Transfer topic only) with client-side
|
|
13
|
+
* recipient filtering, because some RPCs don't support topic[2] filtering
|
|
14
|
+
* in subscriptions. ONLY uses drpc (publicnode silently drops log events).
|
|
15
|
+
*
|
|
16
|
+
* 2. **newHeads + targeted getLogs** — subscribe to new block headers via WS,
|
|
17
|
+
* then do a getLogs for just that block number. Bypasses RPC caching
|
|
18
|
+
* because we query a specific (newly mined) block.
|
|
19
|
+
*
|
|
20
|
+
* 3. HTTP getLogs — rotated across RPCs, 30-120s (server-side cached)
|
|
21
|
+
* 4. HTTP balance check — rotated across RPCs, 30-120s (server-side cached)
|
|
22
|
+
* 5. Status API — Persistence backend (often broken)
|
|
23
|
+
*
|
|
24
|
+
* IMPORTANT: Create the FillWatcher BEFORE the initiate transaction to
|
|
25
|
+
* allow time for WebSocket connection + subscription establishment.
|
|
26
|
+
*/
|
|
27
|
+
export interface FillWatcher {
|
|
28
|
+
/** Non-blocking check: has the fill been detected? */
|
|
29
|
+
isDetected: () => boolean;
|
|
30
|
+
/** How many WS connections are alive? */
|
|
31
|
+
connectedCount: () => number;
|
|
32
|
+
/** Clean up all WebSocket connections. Must be called when done. */
|
|
33
|
+
cleanup: () => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a push-based fill watcher using raw WebSocket eth_subscribe.
|
|
37
|
+
*
|
|
38
|
+
* Two strategies run in parallel:
|
|
39
|
+
* A) eth_subscribe("logs") on drpc — direct Transfer event push (fastest)
|
|
40
|
+
* B) eth_subscribe("newHeads") — on each new block, do a targeted
|
|
41
|
+
* getLogs(blockNum, blockNum) which bypasses RPC caching
|
|
42
|
+
*
|
|
43
|
+
* Call BEFORE signAndExecute to give connections time to establish (~2-5s).
|
|
44
|
+
* Check isDetected() in the polling loop — returns true as soon as a
|
|
45
|
+
* matching Transfer event is found by ANY method.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createFillWatcher(chainId: number, tokenAddress: string, walletAddress: string, onDetected?: () => void): FillWatcher;
|
|
48
|
+
export interface TransferEventResult {
|
|
49
|
+
found: boolean;
|
|
50
|
+
latestBlock?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface BalanceChangeResult {
|
|
53
|
+
changed: boolean;
|
|
54
|
+
newBalance: bigint;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check for fill via ERC20 Transfer event logs over HTTP.
|
|
58
|
+
* Uses fresh providers rotated across RPCs. Subject to server-side
|
|
59
|
+
* caching (30-120s), kept as a fallback for when WS subscription fails.
|
|
60
|
+
*/
|
|
61
|
+
export declare function checkTransferEvents(chainId: number, tokenAddress: string, walletAddress: string, fromBlock: number, rpcIndex: number): Promise<TransferEventResult>;
|
|
62
|
+
/**
|
|
63
|
+
* Check for fill via balance change using a fresh (uncached) provider.
|
|
64
|
+
* Each call cycles through a different RPC via rpcIndex.
|
|
65
|
+
*/
|
|
66
|
+
export declare function checkBalanceChange(chainId: number, tokenAddress: string, walletAddress: string, preBalance: bigint, rpcIndex: number): Promise<BalanceChangeResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Get the current block number on a chain using a fresh provider.
|
|
69
|
+
*/
|
|
70
|
+
export declare function getCurrentBlockNumber(chainId: number): Promise<number>;
|