@0xmonaco/core 1.1.6 → 1.1.8
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/api/fees/api.d.ts +2 -0
- package/dist/api/fees/api.js +2 -0
- package/dist/api/sweeper/api.d.ts +109 -1
- package/dist/api/sweeper/api.js +225 -1
- package/dist/api/sweeper/hypercore.d.ts +103 -0
- package/dist/api/sweeper/hypercore.js +357 -0
- package/dist/api/sweeper/hyperevm.d.ts +50 -0
- package/dist/api/sweeper/hyperevm.js +282 -0
- package/dist/api/vault/api.d.ts +28 -0
- package/dist/api/vault/api.js +136 -42
- package/dist/api/websocket/websocket.js +31 -1
- package/dist/errors/errors.d.ts +44 -0
- package/dist/errors/errors.js +61 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/sdk.js +4 -0
- package/package.json +3 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* USDC deposits from HyperEVM to a Sei deposit address.
|
|
3
|
+
*
|
|
4
|
+
* The wallet burns its Circle USDC on HyperEVM through Circle's
|
|
5
|
+
* TokenMessengerV2, naming the Sei deposit address as the mint recipient and
|
|
6
|
+
* attaching Circle's forwarding hook, so Circle mints on Sei
|
|
7
|
+
* (developers.circle.com/cctp/concepts/forwarding-service). The hub sweeps the
|
|
8
|
+
* mint like any deposit made straight to the address, so nothing of Monaco's
|
|
9
|
+
* runs on HyperEVM. The wallet pays HYPE for the approval and burn.
|
|
10
|
+
*/
|
|
11
|
+
import { BaseError, ChainMismatchError, createPublicClient, createWalletClient, encodeFunctionData, http, keccak256, pad, parseAbi, } from "viem";
|
|
12
|
+
import { APIError, ContractError } from "../../errors";
|
|
13
|
+
import { SEI_CCTP_DOMAIN } from "./hypercore";
|
|
14
|
+
/** HyperEVM's CCTP domain: where the burn happens. */
|
|
15
|
+
const HYPEREVM_CCTP_DOMAIN = 19;
|
|
16
|
+
/**
|
|
17
|
+
* The standard lane. From HyperEVM it attests in about a block (Circle's
|
|
18
|
+
* table: ~5 seconds) and carries no protocol fee, so `maxFee` is Circle's
|
|
19
|
+
* forwarding fee alone.
|
|
20
|
+
*/
|
|
21
|
+
const CCTP_STANDARD_FINALITY = 2000;
|
|
22
|
+
/** Circle's static version-0 forwarding hook: "cctp-forward", version 0, no extra data. */
|
|
23
|
+
const FORWARD_HOOK = "0x636374702d666f72776172640000000000000000000000000000000000000000";
|
|
24
|
+
/** How long to wait for a HyperEVM receipt; small blocks land every second. */
|
|
25
|
+
const RECEIPT_TIMEOUT_MS = 60_000;
|
|
26
|
+
/** EIP-1193 / EIP-3085: the wallet does not know the requested chain. */
|
|
27
|
+
const UNRECOGNIZED_CHAIN = 4902;
|
|
28
|
+
const ERC20_ABI = parseAbi([
|
|
29
|
+
"function balanceOf(address) view returns (uint256)",
|
|
30
|
+
"function allowance(address,address) view returns (uint256)",
|
|
31
|
+
"function approve(address,uint256) returns (bool)",
|
|
32
|
+
]);
|
|
33
|
+
const TOKEN_MESSENGER_ABI = parseAbi([
|
|
34
|
+
"function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData)",
|
|
35
|
+
]);
|
|
36
|
+
function hyperEvmPublicClient(network) {
|
|
37
|
+
return createPublicClient({ chain: network.hyperEvm, transport: http(), pollingInterval: 1_000 });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Circle's forwarding fee for a HyperEVM-to-Sei transfer on the standard lane,
|
|
41
|
+
* at Circle's "high" level. Every forwarded transfer we sent was charged its
|
|
42
|
+
* whole `maxFee`, so the quote is used as is rather than padded; and a
|
|
43
|
+
* `maxFee` below the forwarding fee would leave the mint unforwarded, so the
|
|
44
|
+
* top of Circle's range is the safe choice.
|
|
45
|
+
*/
|
|
46
|
+
export async function quoteForwardFee(network) {
|
|
47
|
+
const endpoint = `${network.irisUrl}/v2/burn/USDC/fees/${HYPEREVM_CCTP_DOMAIN}/${SEI_CCTP_DOMAIN}?forward=true`;
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = await fetch(endpoint);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new APIError("Network request to Circle's fee API failed", { endpoint, cause: error instanceof Error ? error : new Error(String(error)) });
|
|
54
|
+
}
|
|
55
|
+
const text = await response.text().catch(() => "");
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new APIError(`Circle's fee API answered ${response.status}`, { endpoint, statusCode: response.status, responseBody: text.slice(0, 500) });
|
|
58
|
+
}
|
|
59
|
+
let quotes;
|
|
60
|
+
try {
|
|
61
|
+
quotes = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
quotes = undefined;
|
|
65
|
+
}
|
|
66
|
+
const standard = Array.isArray(quotes)
|
|
67
|
+
? quotes.find((quote) => quote.finalityThreshold === CCTP_STANDARD_FINALITY)
|
|
68
|
+
: undefined;
|
|
69
|
+
const fee = standard?.forwardFee?.high;
|
|
70
|
+
if (typeof fee !== "number" || !Number.isSafeInteger(fee) || fee < 0) {
|
|
71
|
+
throw new APIError("Circle's fee API returned no forwarding fee for HyperEVM to Sei", {
|
|
72
|
+
endpoint,
|
|
73
|
+
statusCode: response.status,
|
|
74
|
+
responseBody: text.slice(0, 500),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return BigInt(fee);
|
|
78
|
+
}
|
|
79
|
+
export async function readHyperEvmAccount(network, address) {
|
|
80
|
+
const client = hyperEvmPublicClient(network);
|
|
81
|
+
const [usdc, allowance, gas] = await Promise.all([
|
|
82
|
+
client.readContract({ address: network.hyperEvmUsdc, abi: ERC20_ABI, functionName: "balanceOf", args: [address] }),
|
|
83
|
+
client.readContract({ address: network.hyperEvmUsdc, abi: ERC20_ABI, functionName: "allowance", args: [address, network.tokenMessenger] }),
|
|
84
|
+
client.getBalance({ address }),
|
|
85
|
+
]);
|
|
86
|
+
return { usdc, allowance, gas };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A wallet client that sends on HyperEVM, and how to hand the wallet back.
|
|
90
|
+
*
|
|
91
|
+
* A local account signs for HyperEVM through a client of its own on the public
|
|
92
|
+
* HyperEVM RPC. A browser or embedded wallet is switched to HyperEVM — added
|
|
93
|
+
* first when it does not know the chain — and switched back to the chain it
|
|
94
|
+
* was on afterwards, since the SDK expects it on Sei.
|
|
95
|
+
*/
|
|
96
|
+
async function onHyperEvm(walletClient, network) {
|
|
97
|
+
const account = walletClient.account;
|
|
98
|
+
if (!account) {
|
|
99
|
+
throw new Error("onHyperEvm needs a wallet client with an account");
|
|
100
|
+
}
|
|
101
|
+
if (account.type === "local") {
|
|
102
|
+
return { client: createWalletClient({ account, chain: network.hyperEvm, transport: http() }), restore: async () => { } };
|
|
103
|
+
}
|
|
104
|
+
const home = walletClient.chain;
|
|
105
|
+
try {
|
|
106
|
+
await walletClient.switchChain({ id: network.hyperEvm.id });
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!(error instanceof BaseError) || !error.walk((cause) => cause.code === UNRECOGNIZED_CHAIN)) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
await walletClient.addChain({ chain: network.hyperEvm });
|
|
113
|
+
await walletClient.switchChain({ id: network.hyperEvm.id });
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
client: walletClient,
|
|
117
|
+
restore: async () => {
|
|
118
|
+
if (home) {
|
|
119
|
+
// Best effort: the deposit's outcome does not depend on it.
|
|
120
|
+
await walletClient.switchChain({ id: home.id }).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Approve (when the allowance is short) and burn `amount` of the wallet's
|
|
127
|
+
* HyperEVM USDC to `recipient` on Sei, with Circle forwarding the mint.
|
|
128
|
+
*
|
|
129
|
+
* Decided failures — nothing was burned — throw as they are: a wallet's
|
|
130
|
+
* refusal, a failure before the burn was broadcast, and a `ContractError` for
|
|
131
|
+
* a reverted, cancelled or replaced approval or burn. A burn that may have
|
|
132
|
+
* been broadcast but whose outcome cannot be read is an unknown outcome: the
|
|
133
|
+
* `APIError` carries `retryable: false` and, when known, the burn's hash,
|
|
134
|
+
* because calling again burns a second time. An approval in that state stays
|
|
135
|
+
* retryable, since the next call re-reads the allowance.
|
|
136
|
+
*/
|
|
137
|
+
export async function burnToSei(params) {
|
|
138
|
+
const { walletClient, network, amount, recipient, maxFee, allowance } = params;
|
|
139
|
+
const account = walletClient.account;
|
|
140
|
+
if (!account) {
|
|
141
|
+
throw new Error("burnToSei needs a wallet client with an account");
|
|
142
|
+
}
|
|
143
|
+
const publicClient = hyperEvmPublicClient(network);
|
|
144
|
+
const { client, restore } = await onHyperEvm(walletClient, network);
|
|
145
|
+
try {
|
|
146
|
+
let approveTxHash;
|
|
147
|
+
if (allowance < amount) {
|
|
148
|
+
const approval = encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [network.tokenMessenger, amount] });
|
|
149
|
+
const sent = await send(client, account, network, { to: network.hyperEvmUsdc, data: approval }).catch((error) => {
|
|
150
|
+
throw error instanceof AmbiguousSend ? error.cause : error;
|
|
151
|
+
});
|
|
152
|
+
approveTxHash = await settle(publicClient, sent, "approval", network.hyperEvmUsdc);
|
|
153
|
+
}
|
|
154
|
+
const burn = encodeFunctionData({
|
|
155
|
+
abi: TOKEN_MESSENGER_ABI,
|
|
156
|
+
functionName: "depositForBurnWithHook",
|
|
157
|
+
args: [
|
|
158
|
+
amount,
|
|
159
|
+
SEI_CCTP_DOMAIN,
|
|
160
|
+
pad(recipient, { size: 32 }),
|
|
161
|
+
network.hyperEvmUsdc,
|
|
162
|
+
// Any caller may mint on Sei; Circle's forwarder does.
|
|
163
|
+
pad("0x", { size: 32 }),
|
|
164
|
+
maxFee,
|
|
165
|
+
CCTP_STANDARD_FINALITY,
|
|
166
|
+
FORWARD_HOOK,
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
let burnSent;
|
|
170
|
+
try {
|
|
171
|
+
burnSent = await send(client, account, network, { to: network.tokenMessenger, data: burn });
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw error instanceof AmbiguousSend ? unknownBurnOutcome(error.hash, error.cause, network) : error;
|
|
175
|
+
}
|
|
176
|
+
let burnTxHash;
|
|
177
|
+
try {
|
|
178
|
+
burnTxHash = await settle(publicClient, burnSent, "burn", network.tokenMessenger);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
throw error instanceof ContractError ? error : unknownBurnOutcome(burnSent, error, network);
|
|
182
|
+
}
|
|
183
|
+
return { approveTxHash, burnTxHash };
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await restore();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* EIP-1193 errors a wallet answers without broadcasting anything: the user
|
|
191
|
+
* rejected the request (4001), the account or method is not authorized (4100)
|
|
192
|
+
* or not supported (4200), or the wallet is disconnected (4900, 4901).
|
|
193
|
+
*/
|
|
194
|
+
const WALLET_REFUSALS = new Set([4001, 4100, 4200, 4900, 4901]);
|
|
195
|
+
/** A send that may or may not have reached the network. `hash` is known when the SDK signed it. */
|
|
196
|
+
class AmbiguousSend extends Error {
|
|
197
|
+
hash;
|
|
198
|
+
cause;
|
|
199
|
+
constructor(hash, cause) {
|
|
200
|
+
super("The transaction may have been broadcast");
|
|
201
|
+
this.hash = hash;
|
|
202
|
+
this.cause = cause;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Broadcast one HyperEVM call.
|
|
207
|
+
*
|
|
208
|
+
* A local account's transaction is prepared and signed here, so its hash is
|
|
209
|
+
* known before the broadcast; a failed broadcast may still have reached the
|
|
210
|
+
* network, so it is an {@link AmbiguousSend} carrying that hash. A failure
|
|
211
|
+
* while preparing (a revert in gas estimation, say) sent nothing and passes
|
|
212
|
+
* through. A wallet's refusal, or a wallet on the wrong chain, is decided and
|
|
213
|
+
* passes through too; any other wallet failure may follow a broadcast, so it
|
|
214
|
+
* is ambiguous.
|
|
215
|
+
*/
|
|
216
|
+
async function send(client, account, network, call) {
|
|
217
|
+
if (account.type === "local") {
|
|
218
|
+
const request = await client.prepareTransactionRequest({ account, chain: network.hyperEvm, ...call });
|
|
219
|
+
const serializedTransaction = await client.signTransaction(request);
|
|
220
|
+
const hash = keccak256(serializedTransaction);
|
|
221
|
+
try {
|
|
222
|
+
await client.sendRawTransaction({ serializedTransaction });
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw new AmbiguousSend(hash, error);
|
|
226
|
+
}
|
|
227
|
+
return hash;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return await client.sendTransaction({ account, chain: network.hyperEvm, ...call });
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
const refused = error instanceof BaseError &&
|
|
234
|
+
Boolean(error.walk((cause) => cause instanceof ChainMismatchError || WALLET_REFUSALS.has(cause.code)));
|
|
235
|
+
if (refused) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
throw new AmbiguousSend(undefined, error);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Wait for a sent call's receipt and settle it. viem follows a transaction
|
|
243
|
+
* the wallet replaced: a repriced one (the same call at a new fee) settles as
|
|
244
|
+
* its mined replacement's hash, while a cancelled or otherwise replaced one
|
|
245
|
+
* never ran, so it is a decided failure, as is a revert. A receipt that
|
|
246
|
+
* cannot be read throws viem's error.
|
|
247
|
+
*/
|
|
248
|
+
async function settle(publicClient, hash, what, contract) {
|
|
249
|
+
let replacement;
|
|
250
|
+
const receipt = await publicClient.waitForTransactionReceipt({
|
|
251
|
+
hash,
|
|
252
|
+
timeout: RECEIPT_TIMEOUT_MS,
|
|
253
|
+
onReplaced: (replaced) => {
|
|
254
|
+
replacement = replaced;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
if (replacement && replacement.reason !== "repriced") {
|
|
258
|
+
// The SDK's transaction never ran; a "replaced" one was swapped for a
|
|
259
|
+
// different transaction, whose effects are the wallet's own.
|
|
260
|
+
const message = replacement.reason === "cancelled"
|
|
261
|
+
? `The HyperEVM ${what} was cancelled in the wallet; it did not run`
|
|
262
|
+
: `The HyperEVM ${what} was replaced in the wallet by a different transaction (${receipt.transactionHash}); the ${what} did not run`;
|
|
263
|
+
throw new ContractError(message, { transactionHash: receipt.transactionHash, contractAddress: contract });
|
|
264
|
+
}
|
|
265
|
+
if (receipt.status !== "success") {
|
|
266
|
+
throw new ContractError(`The HyperEVM ${what} reverted; nothing was burned`, { transactionHash: receipt.transactionHash, contractAddress: contract });
|
|
267
|
+
}
|
|
268
|
+
return receipt.transactionHash;
|
|
269
|
+
}
|
|
270
|
+
function unknownBurnOutcome(burnTxHash, cause, network) {
|
|
271
|
+
const error = new APIError(burnTxHash
|
|
272
|
+
? `The HyperEVM burn ${burnTxHash} may have been sent, but its outcome could not be read`
|
|
273
|
+
: "The wallet failed after it may have broadcast the HyperEVM burn", {
|
|
274
|
+
endpoint: network.hyperEvm.rpcUrls.default.http[0],
|
|
275
|
+
...(burnTxHash ? { responseBody: { burnTxHash } } : {}),
|
|
276
|
+
cause: cause instanceof Error ? cause : new Error(String(cause)),
|
|
277
|
+
});
|
|
278
|
+
error.retryable = false;
|
|
279
|
+
error.suggestion =
|
|
280
|
+
"The burn may have landed. Do not call depositFromHyperEvm again, which burns a second time: check the wallet's HyperEVM activity, or sdk.sweeper.listDeposits once it is minted on Sei.";
|
|
281
|
+
return error;
|
|
282
|
+
}
|
package/dist/api/vault/api.d.ts
CHANGED
|
@@ -187,6 +187,30 @@ export declare class VaultAPIImpl extends BaseAPI implements VaultAPI {
|
|
|
187
187
|
* ```
|
|
188
188
|
*/
|
|
189
189
|
deposit(assetId: string, amount: bigint, autoWait?: boolean, target?: DepositTarget, options?: DepositOptions): Promise<TransactionResult>;
|
|
190
|
+
/**
|
|
191
|
+
* Sends a withdrawal's `executeWithdrawal` calldata through the wallet and,
|
|
192
|
+
* with `autoWait`, waits for it.
|
|
193
|
+
*
|
|
194
|
+
* Gas is `EXECUTE_WITHDRAWAL_GAS_HEADROOM_PERCENT` over the node's estimate.
|
|
195
|
+
* A revert at the estimate throws `WithdrawalRateLimitedError` for the vault's
|
|
196
|
+
* rate limit, else `ContractError`. A withdrawal that passed its estimate can
|
|
197
|
+
* still revert on-chain when another withdrawal drains the limit first: if
|
|
198
|
+
* `minedRateLimitRevert` finds the rate limit behind the revert, this throws
|
|
199
|
+
* `WithdrawalRateLimitedError` (with the transaction hash) instead of
|
|
200
|
+
* returning `"failed"`. Every error keeps the index for `retryWithdrawal`.
|
|
201
|
+
*/
|
|
202
|
+
private executeWithdrawalOnChain;
|
|
203
|
+
/**
|
|
204
|
+
* The vault's `WithdrawalRateLimited` revert of a mined, reverted
|
|
205
|
+
* `executeWithdrawal`, if that is why it reverted.
|
|
206
|
+
*
|
|
207
|
+
* Exact where the node serves `debug_traceTransaction`: the transaction's
|
|
208
|
+
* own trace carries the revert it hit at its position in the block. A node
|
|
209
|
+
* without it gets a best-effort replay of the call at the end of the block,
|
|
210
|
+
* which misses a limit a later transaction in the same block refilled (the
|
|
211
|
+
* caller then sees `"failed"`).
|
|
212
|
+
*/
|
|
213
|
+
private minedRateLimitRevert;
|
|
190
214
|
/**
|
|
191
215
|
* Polls `GET /api/v1/withdrawals/{index}` until the gateway can serve the
|
|
192
216
|
* `executeWithdrawal` calldata, then returns it with the vault address.
|
|
@@ -242,6 +266,8 @@ export declare class VaultAPIImpl extends BaseAPI implements VaultAPI {
|
|
|
242
266
|
* transaction plus `withdrawalIndex`; on poll-timeout or `autoWait=false`
|
|
243
267
|
* returns `{ withdrawalIndex, status: "awaiting_proof" }`.
|
|
244
268
|
* @throws {APIError} When the asset is not found or the initiate request is rejected
|
|
269
|
+
* @throws {WithdrawalRateLimitedError} When the vault's withdrawal rate limit
|
|
270
|
+
* refuses the withdrawal; the index stays valid for `retryWithdrawal`
|
|
245
271
|
* @throws {ContractError} When the on-chain submission fails
|
|
246
272
|
* @throws {InvalidConfigError} When wallet account is not available
|
|
247
273
|
*
|
|
@@ -293,6 +319,8 @@ export declare class VaultAPIImpl extends BaseAPI implements VaultAPI {
|
|
|
293
319
|
* @returns Promise resolving to a [`WithdrawResult`]: the submitted transaction
|
|
294
320
|
* plus `withdrawalIndex`, or `{ withdrawalIndex, status: "awaiting_proof" }`
|
|
295
321
|
* if the proof did not arrive within the poll window.
|
|
322
|
+
* @throws {WithdrawalRateLimitedError} When the vault's withdrawal rate limit
|
|
323
|
+
* still refuses the withdrawal; retry again later
|
|
296
324
|
* @throws {ContractError} When the on-chain submission fails
|
|
297
325
|
* @throws {InvalidConfigError} When wallet account is not available
|
|
298
326
|
*/
|
package/dist/api/vault/api.js
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { CONTRACT_ABIS, encodeDepositApplicationData } from "@0xmonaco/contracts";
|
|
27
27
|
import { ApproveTokenSchema, DepositSchema, validate, WithdrawSchema } from "@0xmonaco/types";
|
|
28
|
-
import { erc20Abi } from "viem";
|
|
29
|
-
import { APIError, ContractError, InvalidConfigError } from "../../errors";
|
|
28
|
+
import { decodeErrorResult, erc20Abi } from "viem";
|
|
29
|
+
import { APIError, ContractError, InvalidConfigError, WithdrawalRateLimitedError } from "../../errors";
|
|
30
30
|
import { BaseAPI } from "../base";
|
|
31
31
|
/**
|
|
32
32
|
* Default delay between polls while waiting for a withdrawal's proof. The proof
|
|
@@ -43,6 +43,68 @@ const DEFAULT_WITHDRAWAL_POLL_INTERVAL_MS = 500;
|
|
|
43
43
|
*/
|
|
44
44
|
const DEFAULT_WITHDRAWAL_TIMEOUT_MS = 300_000;
|
|
45
45
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
/**
|
|
47
|
+
* Gas headroom over the node's estimate for `executeWithdrawal`, in percent.
|
|
48
|
+
* Its cost moves with state between estimate and inclusion: while the token
|
|
49
|
+
* has a withdrawal rate limit, the vault writes the limit's offset once its
|
|
50
|
+
* level has refilled past the max, which an estimate taken in the same second
|
|
51
|
+
* as the previous write does not pay for. On dev the bare estimate ran a
|
|
52
|
+
* withdrawal out of gas (148,971 sent, 152,101 needed).
|
|
53
|
+
*/
|
|
54
|
+
const EXECUTE_WITHDRAWAL_GAS_HEADROOM_PERCENT = 20n;
|
|
55
|
+
/** The vault's revert for a withdrawal over the token's withdrawal rate limit. */
|
|
56
|
+
const WITHDRAWAL_RATE_LIMITED_ABI = [
|
|
57
|
+
{
|
|
58
|
+
type: "error",
|
|
59
|
+
name: "WithdrawalRateLimited",
|
|
60
|
+
inputs: [
|
|
61
|
+
{ name: "token", type: "address" },
|
|
62
|
+
{ name: "available", type: "uint256" },
|
|
63
|
+
{ name: "amount", type: "uint256" },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* The vault's `WithdrawalRateLimited` revert, when that is what a failed
|
|
69
|
+
* `executeWithdrawal` submission carries. viem leaves the revert data on the RPC
|
|
70
|
+
* error a few causes down, as hex or wrapped in `{ data }` depending on the node.
|
|
71
|
+
*/
|
|
72
|
+
function decodeWithdrawalRateLimited(error) {
|
|
73
|
+
let current = error;
|
|
74
|
+
for (let depth = 0; current && depth < 16; depth++) {
|
|
75
|
+
const raw = current.data;
|
|
76
|
+
const data = raw !== null && typeof raw === "object" ? raw.data : raw;
|
|
77
|
+
if (typeof data === "string" && data.startsWith("0x")) {
|
|
78
|
+
try {
|
|
79
|
+
// viem also decodes Solidity's built-in Error(string) and
|
|
80
|
+
// Panic(uint256) against any ABI: only the rate limit's own error counts.
|
|
81
|
+
const decoded = decodeErrorResult({ abi: WITHDRAWAL_RATE_LIMITED_ABI, data: data });
|
|
82
|
+
if (decoded.errorName === "WithdrawalRateLimited") {
|
|
83
|
+
const [token, available, amount] = decoded.args;
|
|
84
|
+
return { token, available, amount };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Some other revert.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
current = current.cause;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The error for a failed `executeWithdrawal` submission: a
|
|
97
|
+
* `WithdrawalRateLimitedError` when the vault's rate limit refused it, else a
|
|
98
|
+
* `ContractError`. Both keep the withdrawal index for `retryWithdrawal`.
|
|
99
|
+
*/
|
|
100
|
+
function executeWithdrawalError(action, withdrawalIndex, vaultAddress, error) {
|
|
101
|
+
const cause = error instanceof Error ? error : undefined;
|
|
102
|
+
const rateLimited = decodeWithdrawalRateLimited(error);
|
|
103
|
+
if (rateLimited) {
|
|
104
|
+
return new WithdrawalRateLimitedError(withdrawalIndex, { ...rateLimited, contractAddress: vaultAddress, cause });
|
|
105
|
+
}
|
|
106
|
+
return new ContractError(`Failed to ${action} executeWithdrawal for withdrawal ${withdrawalIndex}: ${error instanceof Error ? error.message : "unknown error"}`, { cause });
|
|
107
|
+
}
|
|
46
108
|
/**
|
|
47
109
|
* Encode the on-chain `applicationData` for a deposit.
|
|
48
110
|
*
|
|
@@ -339,6 +401,72 @@ export class VaultAPIImpl extends BaseAPI {
|
|
|
339
401
|
};
|
|
340
402
|
return await this.waitForTransaction(txResult, autoWait);
|
|
341
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Sends a withdrawal's `executeWithdrawal` calldata through the wallet and,
|
|
406
|
+
* with `autoWait`, waits for it.
|
|
407
|
+
*
|
|
408
|
+
* Gas is `EXECUTE_WITHDRAWAL_GAS_HEADROOM_PERCENT` over the node's estimate.
|
|
409
|
+
* A revert at the estimate throws `WithdrawalRateLimitedError` for the vault's
|
|
410
|
+
* rate limit, else `ContractError`. A withdrawal that passed its estimate can
|
|
411
|
+
* still revert on-chain when another withdrawal drains the limit first: if
|
|
412
|
+
* `minedRateLimitRevert` finds the rate limit behind the revert, this throws
|
|
413
|
+
* `WithdrawalRateLimitedError` (with the transaction hash) instead of
|
|
414
|
+
* returning `"failed"`. Every error keeps the index for `retryWithdrawal`.
|
|
415
|
+
*/
|
|
416
|
+
async executeWithdrawalOnChain(action, withdrawalIndex, vaultAddress, calldata, walletClient, account, autoWait) {
|
|
417
|
+
const request = { account, to: vaultAddress, data: calldata };
|
|
418
|
+
let hash;
|
|
419
|
+
try {
|
|
420
|
+
const estimate = await this.publicClient.estimateGas(request);
|
|
421
|
+
hash = await walletClient.sendTransaction({
|
|
422
|
+
...request,
|
|
423
|
+
chain: this.chain,
|
|
424
|
+
gas: estimate + (estimate * EXECUTE_WITHDRAWAL_GAS_HEADROOM_PERCENT) / 100n,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
throw executeWithdrawalError(action, withdrawalIndex, vaultAddress, error);
|
|
429
|
+
}
|
|
430
|
+
const nonce = account.getNonce ? await account.getNonce() : 0n;
|
|
431
|
+
const settled = await this.waitForTransaction({ hash, status: "pending", nonce }, autoWait);
|
|
432
|
+
if (settled.status === "failed" && settled.receipt) {
|
|
433
|
+
const rateLimited = await this.minedRateLimitRevert(hash, request, settled.receipt.blockNumber);
|
|
434
|
+
if (rateLimited) {
|
|
435
|
+
throw new WithdrawalRateLimitedError(withdrawalIndex, { ...rateLimited, contractAddress: vaultAddress, transactionHash: hash });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { ...settled, withdrawalIndex };
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The vault's `WithdrawalRateLimited` revert of a mined, reverted
|
|
442
|
+
* `executeWithdrawal`, if that is why it reverted.
|
|
443
|
+
*
|
|
444
|
+
* Exact where the node serves `debug_traceTransaction`: the transaction's
|
|
445
|
+
* own trace carries the revert it hit at its position in the block. A node
|
|
446
|
+
* without it gets a best-effort replay of the call at the end of the block,
|
|
447
|
+
* which misses a limit a later transaction in the same block refilled (the
|
|
448
|
+
* caller then sees `"failed"`).
|
|
449
|
+
*/
|
|
450
|
+
async minedRateLimitRevert(hash, request, blockNumber) {
|
|
451
|
+
const traceRequest = this.publicClient.request;
|
|
452
|
+
let trace;
|
|
453
|
+
try {
|
|
454
|
+
trace = (await traceRequest({ method: "debug_traceTransaction", params: [hash, { tracer: "callTracer" }] }));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
trace = undefined;
|
|
458
|
+
}
|
|
459
|
+
if (trace) {
|
|
460
|
+
return decodeWithdrawalRateLimited({ data: trace.output });
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
await this.publicClient.call({ ...request, blockNumber });
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
return decodeWithdrawalRateLimited(error);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
342
470
|
/**
|
|
343
471
|
* Polls `GET /api/v1/withdrawals/{index}` until the gateway can serve the
|
|
344
472
|
* `executeWithdrawal` calldata, then returns it with the vault address.
|
|
@@ -422,6 +550,8 @@ export class VaultAPIImpl extends BaseAPI {
|
|
|
422
550
|
* transaction plus `withdrawalIndex`; on poll-timeout or `autoWait=false`
|
|
423
551
|
* returns `{ withdrawalIndex, status: "awaiting_proof" }`.
|
|
424
552
|
* @throws {APIError} When the asset is not found or the initiate request is rejected
|
|
553
|
+
* @throws {WithdrawalRateLimitedError} When the vault's withdrawal rate limit
|
|
554
|
+
* refuses the withdrawal; the index stays valid for `retryWithdrawal`
|
|
425
555
|
* @throws {ContractError} When the on-chain submission fails
|
|
426
556
|
* @throws {InvalidConfigError} When wallet account is not available
|
|
427
557
|
*
|
|
@@ -492,26 +622,7 @@ export class VaultAPIImpl extends BaseAPI {
|
|
|
492
622
|
}
|
|
493
623
|
const { vaultAddress, calldata } = poll;
|
|
494
624
|
// 3. Submit the calldata on-chain through the connected wallet.
|
|
495
|
-
|
|
496
|
-
try {
|
|
497
|
-
hash = await this.walletClient.sendTransaction({
|
|
498
|
-
to: vaultAddress,
|
|
499
|
-
data: calldata,
|
|
500
|
-
account: walletAccount,
|
|
501
|
-
chain: this.chain,
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
catch (error) {
|
|
505
|
-
throw new ContractError(`Failed to submit executeWithdrawal for withdrawal ${withdrawalIndex}: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error instanceof Error ? error : undefined });
|
|
506
|
-
}
|
|
507
|
-
const nonce = walletAccount.getNonce ? await walletAccount.getNonce() : 0n;
|
|
508
|
-
const txResult = {
|
|
509
|
-
hash,
|
|
510
|
-
status: "pending",
|
|
511
|
-
nonce,
|
|
512
|
-
};
|
|
513
|
-
const settled = await this.waitForTransaction(txResult, autoWait);
|
|
514
|
-
return { ...settled, withdrawalIndex };
|
|
625
|
+
return await this.executeWithdrawalOnChain("submit", withdrawalIndex, vaultAddress, calldata, this.walletClient, walletAccount, autoWait);
|
|
515
626
|
}
|
|
516
627
|
/**
|
|
517
628
|
* Submits (or resubmits) a previously-initiated withdrawal on-chain.
|
|
@@ -536,6 +647,8 @@ export class VaultAPIImpl extends BaseAPI {
|
|
|
536
647
|
* @returns Promise resolving to a [`WithdrawResult`]: the submitted transaction
|
|
537
648
|
* plus `withdrawalIndex`, or `{ withdrawalIndex, status: "awaiting_proof" }`
|
|
538
649
|
* if the proof did not arrive within the poll window.
|
|
650
|
+
* @throws {WithdrawalRateLimitedError} When the vault's withdrawal rate limit
|
|
651
|
+
* still refuses the withdrawal; retry again later
|
|
539
652
|
* @throws {ContractError} When the on-chain submission fails
|
|
540
653
|
* @throws {InvalidConfigError} When wallet account is not available
|
|
541
654
|
*/
|
|
@@ -554,26 +667,7 @@ export class VaultAPIImpl extends BaseAPI {
|
|
|
554
667
|
return { withdrawalIndex, status: "awaiting_proof" };
|
|
555
668
|
}
|
|
556
669
|
const { vaultAddress, calldata } = poll;
|
|
557
|
-
|
|
558
|
-
try {
|
|
559
|
-
hash = await this.walletClient.sendTransaction({
|
|
560
|
-
to: vaultAddress,
|
|
561
|
-
data: calldata,
|
|
562
|
-
account: walletAccount,
|
|
563
|
-
chain: this.chain,
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
catch (error) {
|
|
567
|
-
throw new ContractError(`Failed to resubmit executeWithdrawal for withdrawal ${withdrawalIndex}: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error instanceof Error ? error : undefined });
|
|
568
|
-
}
|
|
569
|
-
const nonce = walletAccount.getNonce ? await walletAccount.getNonce() : 0n;
|
|
570
|
-
const txResult = {
|
|
571
|
-
hash,
|
|
572
|
-
status: "pending",
|
|
573
|
-
nonce,
|
|
574
|
-
};
|
|
575
|
-
const settled = await this.waitForTransaction(txResult, autoWait);
|
|
576
|
-
return { ...settled, withdrawalIndex };
|
|
670
|
+
return await this.executeWithdrawalOnChain("resubmit", withdrawalIndex, vaultAddress, calldata, this.walletClient, walletAccount, autoWait);
|
|
577
671
|
}
|
|
578
672
|
/**
|
|
579
673
|
* Retrieves the user's token balance in the vault.
|
|
@@ -445,6 +445,18 @@ function parseRfqSideViewEventData(raw) {
|
|
|
445
445
|
...(estimatedMonacoFee !== undefined && { estimatedMonacoFee }),
|
|
446
446
|
...(estimatedBuilderFee !== undefined && { estimatedBuilderFee }),
|
|
447
447
|
...(estimatedTotalFee !== undefined && { estimatedTotalFee }),
|
|
448
|
+
...(raw.book != null && { book: parseRfqBookComparisonEventData(raw.book) }),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function parseRfqBookComparisonEventData(raw) {
|
|
452
|
+
if (!isRecord(raw)) {
|
|
453
|
+
throw new Error("Invalid RFQ view: a book comparison must be an object");
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
vwap: readString(raw, "vwap"),
|
|
457
|
+
grossVwap: readString(raw, "grossVwap"),
|
|
458
|
+
asOfMs: readSequence(raw, "asOfMs"),
|
|
459
|
+
levels: readSequence(raw, "levels"),
|
|
448
460
|
};
|
|
449
461
|
}
|
|
450
462
|
/** An optional wire integer that may be negative; absent or `null` is `undefined`. */
|
|
@@ -508,7 +520,7 @@ function parseRfqFillEventData(raw) {
|
|
|
508
520
|
...(builderFee !== undefined && { builderFee }),
|
|
509
521
|
};
|
|
510
522
|
}
|
|
511
|
-
/** Parse the taker's outcome. Nothing filled leaves `vwap` absent and `fills` empty. */
|
|
523
|
+
/** Parse the taker's outcome. Nothing filled leaves `vwap` and `allInVwap` absent and `fills` empty. */
|
|
512
524
|
function parseRfqOutcomeEventData(raw) {
|
|
513
525
|
if (!isRecord(raw)) {
|
|
514
526
|
throw new Error("Invalid RFQ outcome: outcome must be an object");
|
|
@@ -517,11 +529,13 @@ function parseRfqOutcomeEventData(raw) {
|
|
|
517
529
|
throw new Error("Invalid RFQ outcome: fills must be an array");
|
|
518
530
|
}
|
|
519
531
|
const vwap = readOptionalDecimalString(raw, "vwap");
|
|
532
|
+
const allInVwap = readOptionalDecimalString(raw, "allInVwap");
|
|
520
533
|
return {
|
|
521
534
|
executedQuantity: readString(raw, "executedQuantity"),
|
|
522
535
|
unfilledQuantity: readString(raw, "unfilledQuantity"),
|
|
523
536
|
...(vwap !== undefined && { vwap }),
|
|
524
537
|
fills: raw.fills.map(parseRfqFillEventData),
|
|
538
|
+
...(allInVwap !== undefined && { allInVwap }),
|
|
525
539
|
};
|
|
526
540
|
}
|
|
527
541
|
/** Parse an RFQ's end. Only the taker's push carries `outcome`. */
|
|
@@ -624,6 +638,21 @@ function parseAccountEvent(rawData) {
|
|
|
624
638
|
data: parseAccountEventData(rawData.data),
|
|
625
639
|
};
|
|
626
640
|
}
|
|
641
|
+
/** The frame's 24h volume split by source, or undefined when the frame omits it. */
|
|
642
|
+
function parseDailyVolumeBySource(raw) {
|
|
643
|
+
if (raw === undefined || raw === null) {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
if (!isRecord(raw)) {
|
|
647
|
+
throw new Error("Invalid market stats event: daily_volume_by_source must be an object");
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
orderBookBaseTokenVolume: readString(raw, "orderBookBaseTokenVolume"),
|
|
651
|
+
rfqBaseTokenVolume: readString(raw, "rfqBaseTokenVolume"),
|
|
652
|
+
orderBookQuoteTokenVolume: readOptionalString(raw, "orderBookQuoteTokenVolume"),
|
|
653
|
+
rfqQuoteTokenVolume: readOptionalString(raw, "rfqQuoteTokenVolume"),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
627
656
|
function parseMarketStatsData(raw) {
|
|
628
657
|
if (!isRecord(raw)) {
|
|
629
658
|
throw new Error("Invalid market stats event: data must be an object");
|
|
@@ -647,6 +676,7 @@ function parseMarketStatsData(raw) {
|
|
|
647
676
|
dailyPriceHigh: readOptionalString(data, "dailyPriceHigh"),
|
|
648
677
|
dailyPriceChange: readOptionalString(data, "dailyPriceChange"),
|
|
649
678
|
dailyPriceChangePercent: readOptionalString(data, "dailyPriceChangePercent"),
|
|
679
|
+
dailyVolumeBySource: parseDailyVolumeBySource(data.dailyVolumeBySource),
|
|
650
680
|
currentFundingRate: readOptionalString(data, "currentFundingRate"),
|
|
651
681
|
fundingRate: readOptionalString(data, "fundingRate"),
|
|
652
682
|
fundingTimestamp: readOptionalNumber(data, "fundingTimestamp"),
|
package/dist/errors/errors.d.ts
CHANGED
|
@@ -385,3 +385,47 @@ export declare class ContractError extends MonacoCoreError {
|
|
|
385
385
|
});
|
|
386
386
|
toJSON(): Record<string, unknown>;
|
|
387
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Error thrown when the vault refuses a withdrawal because it exceeds the
|
|
390
|
+
* token's withdrawal rate limit (the contract's `WithdrawalRateLimited`
|
|
391
|
+
* revert).
|
|
392
|
+
*
|
|
393
|
+
* The vault caps how much withdrawals against recently confirmed roots can pay
|
|
394
|
+
* out at once, per token, and refills that allowance over time. A refused
|
|
395
|
+
* withdrawal is not lost: its balance is already debited and its index stays
|
|
396
|
+
* valid. Retry it with `sdk.vault.retryWithdrawal(withdrawalIndex)` once the
|
|
397
|
+
* limit has refilled, or once the withdrawal's root is a day old, when the limit
|
|
398
|
+
* no longer applies to it.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```typescript
|
|
402
|
+
* try {
|
|
403
|
+
* await sdk.vault.withdraw(assetId, amount);
|
|
404
|
+
* } catch (error) {
|
|
405
|
+
* if (error instanceof WithdrawalRateLimitedError) {
|
|
406
|
+
* // Keep error.withdrawalIndex and retry later.
|
|
407
|
+
* console.log(`${error.available} of ${error.amount} can be paid out now`);
|
|
408
|
+
* }
|
|
409
|
+
* }
|
|
410
|
+
* ```
|
|
411
|
+
*/
|
|
412
|
+
export declare class WithdrawalRateLimitedError extends ContractError {
|
|
413
|
+
/** The refused withdrawal's index; pass it to `retryWithdrawal`. */
|
|
414
|
+
readonly withdrawalIndex: number;
|
|
415
|
+
/** The limited token's address (WSEI's for native withdrawals). */
|
|
416
|
+
readonly token: string;
|
|
417
|
+
/** What the limit lets withdrawals pay out now, in the token's raw units. */
|
|
418
|
+
readonly available: bigint;
|
|
419
|
+
/** The withdrawal's amount, in the token's raw units. */
|
|
420
|
+
readonly amount: bigint;
|
|
421
|
+
constructor(withdrawalIndex: number, details: {
|
|
422
|
+
token: string;
|
|
423
|
+
available: bigint;
|
|
424
|
+
amount: bigint;
|
|
425
|
+
contractAddress?: string;
|
|
426
|
+
/** Set when the withdrawal was mined and reverted on-chain. */
|
|
427
|
+
transactionHash?: string;
|
|
428
|
+
cause?: Error;
|
|
429
|
+
});
|
|
430
|
+
toJSON(): Record<string, unknown>;
|
|
431
|
+
}
|