@gauntlet-xyz/sdk 3.0.1 → 3.1.1

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.
Files changed (117) hide show
  1. package/README.md +34 -3
  2. package/dist/api/activity.d.ts +3 -1
  3. package/dist/api/activity.d.ts.map +1 -1
  4. package/dist/api/activityRows.d.ts +11 -0
  5. package/dist/api/activityRows.d.ts.map +1 -0
  6. package/dist/api/caip.d.ts.map +1 -1
  7. package/dist/api/client.d.ts.map +1 -1
  8. package/dist/api/decimal.d.ts.map +1 -1
  9. package/dist/api/http.d.ts.map +1 -1
  10. package/dist/api/index.js +7 -8
  11. package/dist/api/positionHistory.d.ts.map +1 -1
  12. package/dist/attribution/index.d.ts +5 -2
  13. package/dist/attribution/index.d.ts.map +1 -1
  14. package/dist/{chunk-HC2BS7MK.js → chunk-JJOLVL5V.js} +113 -6
  15. package/dist/chunk-JJOLVL5V.js.map +7 -0
  16. package/dist/{chunk-S2TTA45N.js → chunk-JRSNLXFY.js} +2 -2
  17. package/dist/{chunk-SU6JAKIA.js → chunk-MEKISE2T.js} +131 -47
  18. package/dist/chunk-MEKISE2T.js.map +7 -0
  19. package/dist/{chunk-7TA2OJUK.js → chunk-YQXTLIJA.js} +209 -96
  20. package/dist/chunk-YQXTLIJA.js.map +7 -0
  21. package/dist/cjs/api/activity.d.ts +3 -1
  22. package/dist/cjs/api/activity.d.ts.map +1 -1
  23. package/dist/cjs/api/activity.js +14 -12
  24. package/dist/cjs/api/activity.js.map +2 -2
  25. package/dist/cjs/api/activityRows.d.ts +11 -0
  26. package/dist/cjs/api/activityRows.d.ts.map +1 -0
  27. package/dist/cjs/api/activityRows.js +139 -0
  28. package/dist/cjs/api/activityRows.js.map +7 -0
  29. package/dist/cjs/api/caip.d.ts.map +1 -1
  30. package/dist/cjs/api/caip.js +3 -1
  31. package/dist/cjs/api/caip.js.map +2 -2
  32. package/dist/cjs/api/client.d.ts.map +1 -1
  33. package/dist/cjs/api/client.js +29 -1
  34. package/dist/cjs/api/client.js.map +2 -2
  35. package/dist/cjs/api/decimal.d.ts.map +1 -1
  36. package/dist/cjs/api/decimal.js +1 -0
  37. package/dist/cjs/api/decimal.js.map +2 -2
  38. package/dist/cjs/api/http.d.ts.map +1 -1
  39. package/dist/cjs/api/http.js +1 -0
  40. package/dist/cjs/api/http.js.map +2 -2
  41. package/dist/cjs/api/positionHistory.d.ts.map +1 -1
  42. package/dist/cjs/api/positionHistory.js +17 -4
  43. package/dist/cjs/api/positionHistory.js.map +2 -2
  44. package/dist/cjs/attribution/index.d.ts +5 -2
  45. package/dist/cjs/attribution/index.d.ts.map +1 -1
  46. package/dist/cjs/attribution/index.js +3 -1
  47. package/dist/cjs/attribution/index.js.map +2 -2
  48. package/dist/cjs/evm/adapters/aera.d.ts.map +1 -1
  49. package/dist/cjs/evm/adapters/aera.js +16 -7
  50. package/dist/cjs/evm/adapters/aera.js.map +2 -2
  51. package/dist/cjs/evm/adapters/types.d.ts +1 -0
  52. package/dist/cjs/evm/adapters/types.d.ts.map +1 -1
  53. package/dist/cjs/evm/adapters/types.js.map +1 -1
  54. package/dist/cjs/evm/aeraContracts.d.ts.map +1 -1
  55. package/dist/cjs/evm/aeraContracts.js +21 -11
  56. package/dist/cjs/evm/aeraContracts.js.map +2 -2
  57. package/dist/cjs/evm/deposit.d.ts +3 -2
  58. package/dist/cjs/evm/deposit.d.ts.map +1 -1
  59. package/dist/cjs/evm/deposit.js +40 -10
  60. package/dist/cjs/evm/deposit.js.map +2 -2
  61. package/dist/cjs/evm/depositQuote.d.ts.map +1 -1
  62. package/dist/cjs/evm/depositQuote.js +1 -0
  63. package/dist/cjs/evm/depositQuote.js.map +2 -2
  64. package/dist/cjs/evm/depositReceiverApproval.d.ts.map +1 -1
  65. package/dist/cjs/evm/depositReceiverApproval.js +14 -9
  66. package/dist/cjs/evm/depositReceiverApproval.js.map +2 -2
  67. package/dist/cjs/evm/userCurrentBalance.d.ts +15 -1
  68. package/dist/cjs/evm/userCurrentBalance.d.ts.map +1 -1
  69. package/dist/cjs/evm/userCurrentBalance.js +94 -51
  70. package/dist/cjs/evm/userCurrentBalance.js.map +2 -2
  71. package/dist/cjs/evm/withdraw.d.ts +2 -0
  72. package/dist/cjs/evm/withdraw.d.ts.map +1 -1
  73. package/dist/cjs/evm/withdraw.js +14 -1
  74. package/dist/cjs/evm/withdraw.js.map +2 -2
  75. package/dist/cjs/evm/withdrawQuote.d.ts.map +1 -1
  76. package/dist/cjs/evm/withdrawQuote.js +1 -0
  77. package/dist/cjs/evm/withdrawQuote.js.map +2 -2
  78. package/dist/evm/adapters/aera.d.ts.map +1 -1
  79. package/dist/evm/adapters/types.d.ts +1 -0
  80. package/dist/evm/adapters/types.d.ts.map +1 -1
  81. package/dist/evm/aeraContracts.d.ts.map +1 -1
  82. package/dist/evm/deposit.d.ts +3 -2
  83. package/dist/evm/deposit.d.ts.map +1 -1
  84. package/dist/evm/depositQuote.d.ts.map +1 -1
  85. package/dist/evm/depositReceiverApproval.d.ts.map +1 -1
  86. package/dist/evm/index.js +6 -8
  87. package/dist/evm/userCurrentBalance.d.ts +15 -1
  88. package/dist/evm/userCurrentBalance.d.ts.map +1 -1
  89. package/dist/evm/withdraw.d.ts +2 -0
  90. package/dist/evm/withdraw.d.ts.map +1 -1
  91. package/dist/evm/withdrawQuote.d.ts.map +1 -1
  92. package/dist/index.js +11 -13
  93. package/dist/privy/index.js +2 -2
  94. package/package.json +1 -1
  95. package/src/api/activity.ts +18 -15
  96. package/src/api/activityRows.ts +143 -0
  97. package/src/api/caip.ts +3 -1
  98. package/src/api/client.ts +36 -1
  99. package/src/api/decimal.ts +1 -0
  100. package/src/api/http.ts +1 -0
  101. package/src/api/positionHistory.ts +20 -5
  102. package/src/attribution/index.ts +6 -2
  103. package/src/evm/adapters/aera.ts +26 -8
  104. package/src/evm/adapters/types.ts +1 -0
  105. package/src/evm/aeraContracts.ts +29 -14
  106. package/src/evm/deposit.ts +43 -12
  107. package/src/evm/depositQuote.ts +1 -0
  108. package/src/evm/depositReceiverApproval.ts +14 -9
  109. package/src/evm/userCurrentBalance.ts +144 -60
  110. package/src/evm/withdraw.ts +20 -1
  111. package/src/evm/withdrawQuote.ts +1 -0
  112. package/dist/chunk-7TA2OJUK.js.map +0 -7
  113. package/dist/chunk-HC2BS7MK.js.map +0 -7
  114. package/dist/chunk-STIV2CC4.js +0 -57
  115. package/dist/chunk-STIV2CC4.js.map +0 -7
  116. package/dist/chunk-SU6JAKIA.js.map +0 -7
  117. /package/dist/{chunk-S2TTA45N.js.map → chunk-JRSNLXFY.js.map} +0 -0
@@ -0,0 +1,143 @@
1
+ import { GauntletSDKError } from '../errors';
2
+ import type { UserActivity } from './client';
3
+ import { decimalToBigInt, sharesToBigInt } from './decimal';
4
+
5
+ export function isPending(row: UserActivity): boolean {
6
+ return row.type === 'deposit_pending' || row.type === 'withdraw_pending';
7
+ }
8
+
9
+ export function requireSameToken(leftRow: UserActivity, rightRow: UserActivity): void {
10
+ // The vault ID includes the chain ID; TokenRef has no chain field.
11
+ if (leftRow.vault_id.split(':')[0] !== rightRow.vault_id.split(':')[0]) {
12
+ throw new GauntletSDKError('Activity rows contain assets on different chains');
13
+ }
14
+ const left = leftRow.assets_delta.token;
15
+ const right = rightRow.assets_delta.token;
16
+ if (
17
+ left != null &&
18
+ right != null &&
19
+ (left.address.toLowerCase() !== right.address.toLowerCase() ||
20
+ (left.decimals ?? null) !== (right.decimals ?? null))
21
+ ) {
22
+ throw new GauntletSDKError('Activity rows contain different asset denominations');
23
+ }
24
+ }
25
+
26
+ export function validateActivityRow(row: UserActivity): void {
27
+ const shares = sharesToBigInt(row.shares_delta);
28
+ const assets = decimalToBigInt(row.assets_delta.native, 18);
29
+ if (
30
+ !Number.isSafeInteger(row.block_number) ||
31
+ row.block_number < 0 ||
32
+ !Number.isSafeInteger(row.block_timestamp) ||
33
+ row.block_timestamp < 0 ||
34
+ typeof row.vault_id !== 'string' ||
35
+ row.vault_id.length === 0 ||
36
+ typeof row.tx_hash !== 'string' ||
37
+ row.tx_hash.length === 0 ||
38
+ (row.request_hash != null &&
39
+ (typeof row.request_hash !== 'string' || row.request_hash.length === 0))
40
+ ) {
41
+ throw new GauntletSDKError('The activity row has invalid event identifiers');
42
+ }
43
+ const token = row.assets_delta.token;
44
+ if (token != null && token.decimals != null) {
45
+ if (!Number.isInteger(token.decimals) || token.decimals < 0 || token.decimals > 255) {
46
+ throw new GauntletSDKError('The activity token has invalid decimals');
47
+ }
48
+ decimalToBigInt(row.assets_delta.native, token.decimals);
49
+ }
50
+ let valid: boolean;
51
+ switch (row.type) {
52
+ case 'deposit':
53
+ valid = shares >= 0n && assets >= 0n && (row.request_hash == null || assets === 0n);
54
+ break;
55
+ case 'deposit_pending':
56
+ valid = shares === 0n && assets > 0n && row.request_hash != null;
57
+ break;
58
+ case 'deposit_refunded':
59
+ valid = shares === 0n && assets < 0n && row.request_hash != null;
60
+ break;
61
+ case 'withdraw':
62
+ valid = shares <= 0n && assets <= 0n && (row.request_hash == null || shares === 0n);
63
+ break;
64
+ case 'withdraw_pending':
65
+ valid = shares < 0n && assets === 0n && row.request_hash != null;
66
+ break;
67
+ case 'withdraw_refunded':
68
+ valid = shares > 0n && assets === 0n && row.request_hash != null;
69
+ break;
70
+ case 'transfer_in':
71
+ valid = shares >= 0n && assets >= 0n && row.request_hash == null;
72
+ break;
73
+ case 'transfer_out':
74
+ valid = shares <= 0n && assets <= 0n && row.request_hash == null;
75
+ break;
76
+ default:
77
+ valid = false;
78
+ }
79
+ if (!valid) throw new GauntletSDKError(`Invalid activity deltas for ${row.type}`);
80
+ }
81
+
82
+ export function orderActivityRows(rows: UserActivity[]): UserActivity[] {
83
+ rows.forEach(validateActivityRow);
84
+ return [...rows].sort(
85
+ (a, b) =>
86
+ a.block_number - b.block_number ||
87
+ a.block_timestamp - b.block_timestamp ||
88
+ Number(isPending(b)) - Number(isPending(a))
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Split request hashes by vault and by each request occurrence.
94
+ * Takes the output of `orderActivityRows`.
95
+ */
96
+ export function activityLifecycles(ordered: UserActivity[]): UserActivity[][] {
97
+ const groups: UserActivity[][] = [];
98
+ const latest = new Map<string, UserActivity[]>();
99
+ for (const row of ordered) {
100
+ if (row.request_hash == null) {
101
+ groups.push([row]);
102
+ continue;
103
+ }
104
+ const key = `${row.vault_id}:${row.request_hash}`;
105
+ const previous = latest.get(key);
106
+ if (previous === undefined || isPending(row)) {
107
+ if (previous !== undefined && previous.every(isPending)) {
108
+ throw new GauntletSDKError('An activity request has duplicate pending rows');
109
+ }
110
+ const group = [row];
111
+ latest.set(key, group);
112
+ groups.push(group);
113
+ continue;
114
+ }
115
+ if (previous.some((entry) => !isPending(entry))) {
116
+ throw new GauntletSDKError('An activity request has conflicting terminal rows');
117
+ }
118
+ const pending = previous[0];
119
+ requireSameToken(pending, row);
120
+ const expected =
121
+ pending.type === 'deposit_pending'
122
+ ? ['deposit', 'deposit_refunded']
123
+ : ['withdraw', 'withdraw_refunded'];
124
+ if (!expected.includes(row.type)) {
125
+ throw new GauntletSDKError('The activity terminal does not match its request');
126
+ }
127
+ if (
128
+ row.type === 'deposit_refunded' &&
129
+ decimalToBigInt(pending.assets_delta.native, 18) !==
130
+ -decimalToBigInt(row.assets_delta.native, 18)
131
+ ) {
132
+ throw new GauntletSDKError('The deposit refund does not match its request');
133
+ }
134
+ if (
135
+ row.type === 'withdraw_refunded' &&
136
+ sharesToBigInt(pending.shares_delta) !== -sharesToBigInt(row.shares_delta)
137
+ ) {
138
+ throw new GauntletSDKError('The withdrawal refund does not match its request');
139
+ }
140
+ previous.push(row);
141
+ }
142
+ return groups;
143
+ }
package/src/api/caip.ts CHANGED
@@ -16,7 +16,9 @@ export interface ApiVaultId {
16
16
  export function parseApiVaultId(id: string): ApiVaultId {
17
17
  const match = /^(?:eip155:)?(\d+):(0x[0-9a-fA-F]{40})$/.exec(id);
18
18
  if (!match) throw new InvalidCaipIdError(id);
19
- return { chainId: Number(match[1]), address: match[2].toLowerCase() };
19
+ const chainId = Number(match[1]);
20
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new InvalidCaipIdError(id);
21
+ return { chainId, address: match[2].toLowerCase() };
20
22
  }
21
23
 
22
24
  export function formatApiVaultId(chainId: number, address: string): string {
package/src/api/client.ts CHANGED
@@ -1,8 +1,14 @@
1
+ import { parseApiVaultId } from './caip';
2
+ import { GauntletApiError } from './errors';
1
3
  import type { components } from './generated';
2
4
  import { apiGet, type GauntletApiConfig, type QueryParams } from './http';
3
5
 
4
6
  type Schemas = components['schemas'];
5
7
 
8
+ function activityError(message: string, code?: string): GauntletApiError {
9
+ return new GauntletApiError({ status: 200, path: '/v1/users/activity', message, code });
10
+ }
11
+
6
12
  export type UserActivity = Schemas['UserActivity'];
7
13
  export type UserActivityResponse = Schemas['UserActivityResponse'];
8
14
  export type UserPosition = Schemas['UserPosition'];
@@ -248,11 +254,40 @@ export class GauntletApi {
248
254
  walletAddress: string,
249
255
  options: Omit<ActivityOptions, 'next'> = {}
250
256
  ): AsyncGenerator<UserActivity> {
257
+ const vault = options.vaultId === undefined ? undefined : parseApiVaultId(options.vaultId);
258
+ // Gaia pages on (timestamp, logIndex), which rows do not expose. Rows
259
+ // can repeat legitimately inside one transaction, so the boundary check
260
+ // is the timestamp order.
261
+ const descending = (options.order ?? 'desc') === 'desc';
262
+ let lastTimestamp: number | undefined;
251
263
  let next: string | undefined;
264
+ const cursors = new Set<string>();
252
265
  do {
253
266
  const page = await this.activity(walletAddress, { ...options, next });
254
- yield* page.data;
267
+ if (page.meta.partial_errors?.length) {
268
+ throw activityError('The activity response is incomplete', 'INCOMPLETE_ACTIVITY');
269
+ }
270
+ for (const row of page.data) {
271
+ if (vault !== undefined) {
272
+ const rowVault = parseApiVaultId(row.vault_id);
273
+ if (rowVault.chainId !== vault.chainId || rowVault.address !== vault.address) {
274
+ throw activityError('The activity vault does not match the request');
275
+ }
276
+ }
277
+ if (
278
+ lastTimestamp !== undefined &&
279
+ (descending ? row.block_timestamp > lastTimestamp : row.block_timestamp < lastTimestamp)
280
+ ) {
281
+ throw activityError('The activity rows are out of order');
282
+ }
283
+ lastTimestamp = row.block_timestamp;
284
+ yield row;
285
+ }
255
286
  next = page.meta.next_cursor ?? undefined;
287
+ if (next !== undefined) {
288
+ if (cursors.has(next)) throw activityError('The activity cursor repeats');
289
+ cursors.add(next);
290
+ }
256
291
  } while (next);
257
292
  }
258
293
 
@@ -17,6 +17,7 @@ export function decimalToBigInt(value: string, decimals: number): bigint {
17
17
  if (!Number.isInteger(decimals) || decimals < 0) {
18
18
  throw new InvalidDecimalError(`decimals=${decimals}`);
19
19
  }
20
+ if (typeof value !== 'string') throw new InvalidDecimalError(String(value));
20
21
  const match = DECIMAL_RE.exec(value);
21
22
  if (!match) throw new InvalidDecimalError(value);
22
23
 
package/src/api/http.ts CHANGED
@@ -39,6 +39,7 @@ export async function apiGet<T>(
39
39
 
40
40
  const fetchImpl = config.fetch ?? fetch;
41
41
  const response = await fetchImpl(url, {
42
+ redirect: 'error',
42
43
  headers: {
43
44
  accept: 'application/json',
44
45
  ...(config.apiKey ? { 'x-api-key': config.apiKey } : {}),
@@ -1,3 +1,4 @@
1
+ import { activityLifecycles, orderActivityRows, requireSameToken } from './activityRows';
1
2
  import { GauntletSDKError } from '../errors';
2
3
  import type { GauntletApi, TokenRef, UserActivity } from './client';
3
4
  import { bigIntToDecimal, decimalToBigInt, sharesToBigInt } from './decimal';
@@ -57,8 +58,11 @@ export function buildPositionHistory(rows: UserActivity[]): PositionHistory {
57
58
  );
58
59
  }
59
60
 
60
- const ordered = [...rows].sort((a, b) => a.block_timestamp - b.block_timestamp);
61
- const token = ordered.find((r) => r.assets_delta.token)?.assets_delta.token ?? null;
61
+ const ordered = orderActivityRows(rows);
62
+ activityLifecycles(ordered);
63
+ const tokenRow = ordered.find((r) => r.assets_delta.token) ?? ordered[0];
64
+ const token = tokenRow.assets_delta.token ?? null;
65
+ for (const row of ordered) requireSameToken(tokenRow, row);
62
66
 
63
67
  let sharesBalance = 0n;
64
68
  let pendingDepositAssets = 0n;
@@ -95,9 +99,14 @@ export function buildPositionHistory(rows: UserActivity[]): PositionHistory {
95
99
  if (row.request_hash != null) pendingDepositByHash.set(row.request_hash, assetsDelta);
96
100
  break;
97
101
  case 'deposit_refunded':
98
- // Row carries the unwound request as a negative delta.
102
+ if (
103
+ row.request_hash == null ||
104
+ pendingDepositByHash.get(row.request_hash) !== -assetsDelta
105
+ ) {
106
+ throw new GauntletSDKError('The deposit refund does not match its escrow');
107
+ }
99
108
  pendingDepositAssets += assetsDelta;
100
- if (row.request_hash != null) pendingDepositByHash.delete(row.request_hash);
109
+ pendingDepositByHash.delete(row.request_hash);
101
110
  break;
102
111
  case 'withdraw':
103
112
  // Sync withdraws and async terminal solves both carry the asset
@@ -121,8 +130,11 @@ export function buildPositionHistory(rows: UserActivity[]): PositionHistory {
121
130
  break;
122
131
  }
123
132
  case 'withdraw_refunded':
133
+ if (row.request_hash == null || pendingRedeemByHash.get(row.request_hash) !== sharesDelta) {
134
+ throw new GauntletSDKError('The withdrawal refund does not match its escrow');
135
+ }
124
136
  pendingRedeemShares -= sharesDelta;
125
- if (row.request_hash != null) pendingRedeemByHash.delete(row.request_hash);
137
+ pendingRedeemByHash.delete(row.request_hash);
126
138
  break;
127
139
  case 'transfer_in':
128
140
  case 'transfer_out':
@@ -131,6 +143,9 @@ export function buildPositionHistory(rows: UserActivity[]): PositionHistory {
131
143
  throw new GauntletSDKError(`Unknown activity row type "${row.type}"`);
132
144
  }
133
145
 
146
+ if (sharesBalance < 0n || pendingDepositAssets < 0n || pendingRedeemShares < 0n) {
147
+ throw new GauntletSDKError('The activity history produces a negative position balance');
148
+ }
134
149
  points.push({
135
150
  timestamp: new Date(row.block_timestamp * 1000),
136
151
  txHash: row.tx_hash,
@@ -8,13 +8,14 @@ export { encodeBuilderCode } from './erc8021';
8
8
  export type PreparedPayloadWithAttribution = {
9
9
  type: string;
10
10
  to: Address;
11
+ chainId: number;
11
12
  data: Hex;
12
13
  account?: Address;
13
14
  };
14
15
 
15
16
  export type PreparedTx = {
16
17
  payload: PreparedPayloadWithAttribution;
17
- tx: TxStep;
18
+ tx: TxStep & { chainId: number };
18
19
  };
19
20
 
20
21
  /**
@@ -38,7 +39,8 @@ export async function buildAttribution(client: GauntletClient): Promise<Hex> {
38
39
 
39
40
  export async function encodeTransactionWithAttribution(
40
41
  client: GauntletClient,
41
- transaction: TxStep
42
+ transaction: TxStep,
43
+ chainId: number
42
44
  ): Promise<PreparedTx> {
43
45
  const customAttribution = await buildAttribution(client);
44
46
  const encodedTx = encodeFunctionData({
@@ -52,10 +54,12 @@ export async function encodeTransactionWithAttribution(
52
54
  type: transaction.type,
53
55
  account: transaction.account,
54
56
  to: transaction.address,
57
+ chainId,
55
58
  data: encodedTxWithAttribution,
56
59
  },
57
60
  tx: {
58
61
  ...transaction,
62
+ chainId,
59
63
  attribution: customAttribution,
60
64
  },
61
65
  };
@@ -430,17 +430,35 @@ export const aeraAdapter: EvmProtocolAdapter = {
430
430
  throw new InvalidWithdrawParamsError();
431
431
  }
432
432
 
433
- const tokenOutBeforeTip = await getAsyncRedeemTokenOut(
434
- publicClient,
435
- runtime,
436
- vault.vaultAddress,
437
- asset.address,
438
- shares
439
- );
440
- minTokenOut = applySlippageDown(subtractSolverTip(tokenOutBeforeTip, solverTip), slippageBps);
433
+ const tokensOut =
434
+ 'amount' in params && params.amount != null
435
+ ? params.amount
436
+ : subtractSolverTip(
437
+ await getAsyncRedeemTokenOut(
438
+ publicClient,
439
+ runtime,
440
+ vault.vaultAddress,
441
+ asset.address,
442
+ shares
443
+ ),
444
+ solverTip
445
+ );
446
+ minTokenOut = applySlippageDown(tokensOut, slippageBps);
441
447
  maxUnitsIn = shares;
442
448
  }
443
449
 
450
+ if (params.maxShares !== undefined) {
451
+ if (
452
+ isAsync ||
453
+ typeof params.maxShares !== 'bigint' ||
454
+ params.maxShares <= 0n ||
455
+ (!isV2ExactTokenWithdraw && shares !== undefined && shares > params.maxShares)
456
+ ) {
457
+ throw new InvalidWithdrawParamsError();
458
+ }
459
+ if (maxUnitsIn > params.maxShares) maxUnitsIn = params.maxShares;
460
+ }
461
+
444
462
  const steps: EvmTxStep[] = [];
445
463
 
446
464
  if (isAsync) {
@@ -57,6 +57,7 @@ export type AdapterWithdrawParams = {
57
57
  maxPriceAge?: bigint;
58
58
  aeraRuntime?: AeraRuntimeContracts;
59
59
  syncWithdrawQuote?: SyncWithdrawQuoteBounds;
60
+ maxShares?: bigint;
60
61
  } & (
61
62
  | { shares: bigint; amount?: never; entireAmount?: never }
62
63
  | { amount: bigint; shares?: never; entireAmount?: never }
@@ -17,8 +17,6 @@ import { MAX_BPS } from '../constants';
17
17
  import { ContractVersion, type EvmVaultDeployment } from './types';
18
18
  import { readAtBlock } from './aeraContracts/readOptions';
19
19
 
20
- const contractVersionCache = new Map<string, ContractVersion>();
21
-
22
20
  const contractVersionAbi = [
23
21
  {
24
22
  type: 'function',
@@ -81,10 +79,6 @@ export function applySlippageUp(amount: bigint, slippageBps: number): bigint {
81
79
  return (amount * (MAX_BPS + BigInt(slippageBps)) + MAX_BPS - 1n) / MAX_BPS;
82
80
  }
83
81
 
84
- function contractVersionCacheKey(client: PublicClient, address: Address): string {
85
- return `${client.chain?.id ?? 'unknown'}:${address.toLowerCase()}`;
86
- }
87
-
88
82
  function isVersionUnavailableError(error: unknown): boolean {
89
83
  if (!(error instanceof ContractFunctionExecutionError) || error.functionName !== 'version') {
90
84
  return false;
@@ -122,15 +116,35 @@ export function getMultiDepositorVault<T extends Client>(
122
116
  });
123
117
  }
124
118
 
119
+ // Latest-block versions per client. Historical reads and failed reads are not cached.
120
+ const latestContractVersions = new WeakMap<PublicClient, Map<string, ContractVersion>>();
121
+
125
122
  export async function resolveContractVersion(
126
123
  client: PublicClient,
127
124
  address: Address,
128
125
  options: AeraReadOptions = {}
129
126
  ): Promise<ContractVersion> {
130
- const cacheKey = contractVersionCacheKey(client, address);
131
- const cached = contractVersionCache.get(cacheKey);
132
- if (cached) return cached;
127
+ if (options.blockNumber !== undefined) return readContractVersion(client, address, options);
133
128
 
129
+ let versions = latestContractVersions.get(client);
130
+ if (versions === undefined) {
131
+ versions = new Map();
132
+ latestContractVersions.set(client, versions);
133
+ }
134
+ const key = address.toLowerCase();
135
+ const cached = versions.get(key);
136
+ if (cached !== undefined) return cached;
137
+
138
+ const version = await readContractVersion(client, address, options);
139
+ versions.set(key, version);
140
+ return version;
141
+ }
142
+
143
+ async function readContractVersion(
144
+ client: PublicClient,
145
+ address: Address,
146
+ options: AeraReadOptions
147
+ ): Promise<ContractVersion> {
134
148
  try {
135
149
  const versionString = await client.readContract({
136
150
  address,
@@ -138,14 +152,15 @@ export async function resolveContractVersion(
138
152
  functionName: 'version',
139
153
  ...readAtBlock(options),
140
154
  });
141
- const version = versionString.startsWith('2.') ? ContractVersion.V2 : ContractVersion.V1;
142
- contractVersionCache.set(cacheKey, version);
143
-
144
- return version;
155
+ if (!/^[12]\./.test(versionString)) {
156
+ throw new UnsupportedFeatureError(`Aera: unsupported contract version ${versionString}`);
157
+ }
158
+ return versionString.startsWith('2.') ? ContractVersion.V2 : ContractVersion.V1;
145
159
  } catch (error) {
146
160
  if (!isVersionUnavailableError(error)) throw error;
147
161
 
148
- contractVersionCache.set(cacheKey, ContractVersion.V1);
162
+ const code = await client.getCode({ address, ...readAtBlock(options) });
163
+ if (code === undefined || code === '0x') throw error;
149
164
  return ContractVersion.V1;
150
165
  }
151
166
  }
@@ -48,8 +48,9 @@ export interface EvmDepositParams {
48
48
  /**
49
49
  * Builds the ordered list of EVM transactions required to deposit into a Gauntlet vault.
50
50
  *
51
- * Returns one or two transactions: an ERC-20 approval (only if the current allowance is
52
- * insufficient) followed by the deposit or async deposit request. Each transaction is
51
+ * Returns ordered transactions: ERC-20 approvals (if the allowance is insufficient),
52
+ * followed by the deposit or async deposit request. Some tokens require an approval
53
+ * of zero before a new nonzero approval. Each transaction is
53
54
  * returned as a `PreparedTx` object ready to be signed and sent.
54
55
  *
55
56
  * @param client - A configured `GauntletClient` instance with an EVM public client and wallet.
@@ -77,6 +78,7 @@ export async function getDepositTx(
77
78
  client: GauntletClient,
78
79
  params: EvmDepositParams
79
80
  ): Promise<PreparedTx[]> {
81
+ params = structuredClone(params);
80
82
  const slippageBps = params.slippageBps ?? DEFAULT_BPS;
81
83
  if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > Number(MAX_BPS)) {
82
84
  throw new InvalidSlippageBPSError(slippageBps);
@@ -141,7 +143,7 @@ export async function getDepositTx(
141
143
  : vault.vaultAddress;
142
144
 
143
145
  // NOTE: this could be moved to the buildDeposit level
144
- const { sufficient } = await adapter.checkAllowance({
146
+ const { sufficient, current } = await adapter.checkAllowance({
145
147
  publicClient,
146
148
  token: token.address,
147
149
  owner: account,
@@ -152,14 +154,43 @@ export async function getDepositTx(
152
154
  const steps: EvmTxStep[] = [];
153
155
 
154
156
  if (!sufficient) {
155
- steps.push(
156
- adapter.buildApproval({
157
- token: token.address,
158
- spender,
159
- amount: params.amount,
160
- account,
161
- })
162
- );
157
+ const approval = adapter.buildApproval({
158
+ token: token.address,
159
+ spender,
160
+ amount: params.amount,
161
+ account,
162
+ });
163
+ if (current > 0n && params.amount > 0n) {
164
+ try {
165
+ await publicClient.estimateContractGas({
166
+ address: approval.address,
167
+ abi: approval.abi,
168
+ functionName: 'approve',
169
+ args: approval.args,
170
+ account,
171
+ });
172
+ } catch (approvalError) {
173
+ const reset = adapter.buildApproval({
174
+ token: token.address,
175
+ spender,
176
+ amount: 0n,
177
+ account,
178
+ });
179
+ try {
180
+ await publicClient.estimateContractGas({
181
+ address: reset.address,
182
+ abi: reset.abi,
183
+ functionName: 'approve',
184
+ args: reset.args,
185
+ account,
186
+ });
187
+ } catch {
188
+ throw approvalError;
189
+ }
190
+ steps.push(reset);
191
+ }
192
+ }
193
+ steps.push(approval);
163
194
  }
164
195
 
165
196
  const depositSteps = await adapter.buildDeposit({
@@ -178,5 +209,5 @@ export async function getDepositTx(
178
209
  });
179
210
  steps.push(...depositSteps);
180
211
 
181
- return Promise.all(steps.map((step) => encodeTransactionWithAttribution(client, step)));
212
+ return Promise.all(steps.map((step) => encodeTransactionWithAttribution(client, step, chainId)));
182
213
  }
@@ -95,6 +95,7 @@ export async function getSyncDepositQuote(
95
95
  client: GauntletClient,
96
96
  params: SyncDepositQuoteParams
97
97
  ): Promise<SyncDepositQuote> {
98
+ params = structuredClone(params);
98
99
  const slippageBps = params.slippageBps ?? DEFAULT_BPS;
99
100
  if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > Number(MAX_BPS)) {
100
101
  throw new InvalidSlippageBPSError(slippageBps);
@@ -25,6 +25,7 @@ export async function getDepositReceiverApprovalTx(
25
25
  client: GauntletClient,
26
26
  params: EvmDepositReceiverApprovalParams
27
27
  ): Promise<PreparedTx> {
28
+ params = { ...params };
28
29
  const resolved = await resolveVault(client, params.vaultId, params.chainId);
29
30
  if (!resolved) throw new VaultNotFoundError(params.vaultId);
30
31
 
@@ -39,13 +40,17 @@ export async function getDepositReceiverApprovalTx(
39
40
  throw new UnsupportedFeatureError('Aera: deposit receiver approval on V1');
40
41
  }
41
42
 
42
- return encodeTransactionWithAttribution(client, {
43
- type: 'setDepositReceiverApproval',
44
- ...provisionerV2.setDepositReceiverApprovalTxRequest(
45
- runtime.provisioner.address,
46
- params.depositor,
47
- params.approved ?? true,
48
- receiver
49
- ),
50
- });
43
+ return encodeTransactionWithAttribution(
44
+ client,
45
+ {
46
+ type: 'setDepositReceiverApproval',
47
+ ...provisionerV2.setDepositReceiverApprovalTxRequest(
48
+ runtime.provisioner.address,
49
+ params.depositor,
50
+ params.approved ?? true,
51
+ receiver
52
+ ),
53
+ },
54
+ chainId
55
+ );
51
56
  }