Skip to content

Commit ceb065d

Browse files
authored
etherfi: gate claim list by on-chain finalized+valid (#304)
Subgraph can report already-claimed requests as claimable, so claimEtherFiWithdrawals reverted with "ERC721: invalid token ID".
1 parent 9e47a3c commit ceb065d

2 files changed

Lines changed: 146 additions & 11 deletions

File tree

src/js/tasks/etherfiQueue.js

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { gql } = require("@apollo/client/core");
2-
const { parseUnits } = require("ethers");
2+
const { Contract, parseUnits } = require("ethers");
33

44
const { baseWithdrawAmount } = require("./liquidityAutomation");
55
const {
@@ -8,13 +8,19 @@ const {
88
requestBaseAssetWithdrawal,
99
resolveArmBase,
1010
} = require("../utils/arm");
11+
const addresses = require("../utils/addresses");
1112
const { createApolloClient } = require("../utils/apollo");
1213
const { logTxDetails } = require("../utils/txLogger");
1314

1415
const log = require("../utils/logger")("task:etherfiQueue");
1516

1617
const uri = "https://origin.squids.live/ops-squid/graphql";
1718

19+
const ETHERFI_WITHDRAWAL_NFT_ABI = [
20+
"function isFinalized(uint256 requestId) view returns (bool)",
21+
"function getRequest(uint256 requestId) view returns (tuple(uint96 amountOfEEth, uint96 shareOfEEth, bool isValid, uint32 feeGwei))",
22+
];
23+
1824
const requestEtherFiWithdrawals = async (options) => {
1925
const { signer, amount } = options;
2026
const baseContext = await resolveArmBase(options);
@@ -43,7 +49,7 @@ const claimEtherFiWithdrawals = async (options) => {
4349
? // If an id is provided, just claim that one
4450
[id]
4551
: // Get the outstanding EtherFi withdrawal requests for the ARM
46-
await claimableEtherFiRequests();
52+
await claimableEtherFiRequests(signer);
4753

4854
if (baseContext.version === "legacy") {
4955
if (requestIds.length > 0) {
@@ -84,7 +90,32 @@ const claimEtherFiWithdrawals = async (options) => {
8490
await logTxDetails(tx, "claim EtherFi withdraws");
8591
};
8692

87-
const claimableEtherFiRequests = async () => {
93+
// Read the on-chain finalized/valid state of each subgraph-reported request.
94+
// EtherFi never reverts on these views (isFinalized is a numeric comparison and
95+
// getRequest returns a zeroed struct for burnt/unknown ids), so one stale id
96+
// can't break the batch.
97+
const etherFiRequestStatuses = async (withdrawalNFT, requestIds) =>
98+
Promise.all(
99+
requestIds.map(async (requestId) => {
100+
const [isFinalized, request] = await Promise.all([
101+
withdrawalNFT.isFinalized(requestId),
102+
withdrawalNFT.getRequest(requestId),
103+
]);
104+
return { requestId, isFinalized, isValid: request.isValid };
105+
}),
106+
);
107+
108+
// Only finalized requests whose withdrawal NFT still exists (isValid) can be
109+
// claimed. EtherFi's isFinalized() stays true after a claim and the ops-squid
110+
// subgraph can lag on its `claimed` flag, so without this on-chain gate an
111+
// already-claimed request keeps coming back and claimEtherFiWithdrawals reverts
112+
// with "ERC721: invalid token ID" (the burnt NFT), wedging the action.
113+
const selectClaimableEtherFiRequests = (statuses) =>
114+
statuses
115+
.filter(({ isFinalized, isValid }) => isFinalized && isValid)
116+
.map(({ requestId }) => requestId);
117+
118+
const claimableEtherFiRequests = async (signer) => {
88119
const client = createApolloClient(uri);
89120

90121
log(`About to get claimable EtherFi withdrawal requests`);
@@ -100,28 +131,47 @@ const claimableEtherFiRequests = async () => {
100131
}
101132
`;
102133

134+
let candidateIds;
103135
try {
104136
const { data } = await client.query({
105137
query,
106138
});
107-
108-
const claimableRequests = data.etherfiWithdrawalRequests.map(
139+
candidateIds = data.etherfiWithdrawalRequests.map(
109140
(request) => request.requestId,
110141
);
111-
112-
log(
113-
`Found ${claimableRequests.length} claimable withdrawal requests: ${claimableRequests}`,
114-
);
115-
116-
return claimableRequests;
117142
} catch (error) {
118143
const msg = `Failed to get claimable EtherFi withdrawal requests`;
119144
console.error(msg);
120145
throw Error(msg, { cause: error });
121146
}
147+
148+
const withdrawalNFT = new Contract(
149+
addresses.mainnet.etherfiWithdrawalQueue,
150+
ETHERFI_WITHDRAWAL_NFT_ABI,
151+
signer,
152+
);
153+
const statuses = await etherFiRequestStatuses(withdrawalNFT, candidateIds);
154+
const claimableRequests = selectClaimableEtherFiRequests(statuses);
155+
156+
const skipped = statuses
157+
.filter(({ isFinalized, isValid }) => !(isFinalized && isValid))
158+
.map(({ requestId }) => requestId);
159+
if (skipped.length > 0) {
160+
log(
161+
`Skipping ${skipped.length} subgraph requests not claimable on-chain (already claimed or not finalized): ${skipped}`,
162+
);
163+
}
164+
165+
log(
166+
`Found ${claimableRequests.length} claimable withdrawal requests: ${claimableRequests}`,
167+
);
168+
169+
return claimableRequests;
122170
};
123171

124172
module.exports = {
125173
requestEtherFiWithdrawals,
126174
claimEtherFiWithdrawals,
175+
etherFiRequestStatuses,
176+
selectClaimableEtherFiRequests,
127177
};

test/js/etherfiQueue.test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
const assert = require("assert");
2+
3+
const { AbiCoder, Contract, id } = require("ethers");
4+
5+
const {
6+
etherFiRequestStatuses,
7+
selectClaimableEtherFiRequests,
8+
} = require("../../src/js/tasks/etherfiQueue");
9+
10+
const coder = AbiCoder.defaultAbiCoder();
11+
12+
const selector = (signature) => id(signature).slice(0, 10);
13+
14+
const run = async () => {
15+
// Pure filter: finalized AND still valid on-chain is the only claimable state.
16+
{
17+
const statuses = [
18+
// Already claimed: the NFT is burnt so isValid is false even though
19+
// EtherFi still reports isFinalized true. Regression for the recurring
20+
// "ERC721: invalid token ID" failure (mainnet request 80636).
21+
{ requestId: 80636, isFinalized: true, isValid: false },
22+
// Requested but not yet finalized: valid but can't be claimed yet.
23+
{ requestId: 80648, isFinalized: false, isValid: true },
24+
// Genuinely claimable.
25+
{ requestId: 80641, isFinalized: true, isValid: true },
26+
];
27+
28+
assert.deepStrictEqual(selectClaimableEtherFiRequests(statuses), [80641]);
29+
}
30+
31+
// End-to-end status read + filter against a mock WithdrawRequestNFT, proving
32+
// the stale request 80636 that wedged autoClaimEtherFiWithdraw is dropped.
33+
{
34+
const selectors = {
35+
isFinalized: selector("isFinalized(uint256)"),
36+
getRequest: selector("getRequest(uint256)"),
37+
};
38+
const requestId = (data) => BigInt(`0x${data.slice(10)}`).toString();
39+
40+
// isFinalized mirrors lastFinalizedRequestId: true for ids <= 80647.
41+
const finalizedById = { 80636: true, 80641: true, 80648: false };
42+
// isValid is false for the already-claimed/burnt 80636.
43+
const validById = { 80636: false, 80641: true, 80648: true };
44+
45+
const runner = {
46+
call: async (tx) => {
47+
const fn = tx.data.slice(0, 10);
48+
const key = requestId(tx.data);
49+
if (fn === selectors.isFinalized) {
50+
return coder.encode(["bool"], [finalizedById[key]]);
51+
}
52+
if (fn === selectors.getRequest) {
53+
return coder.encode(
54+
["tuple(uint96,uint96,bool,uint32)"],
55+
[[0n, 0n, validById[key], 0]],
56+
);
57+
}
58+
throw new Error(`unexpected selector ${tx.data}`);
59+
},
60+
};
61+
62+
const withdrawalNFT = new Contract(
63+
"0x7d5706f6ef3F89B3951E23e557CDFBC3239D4E2c",
64+
[
65+
"function isFinalized(uint256 requestId) view returns (bool)",
66+
"function getRequest(uint256 requestId) view returns (tuple(uint96 amountOfEEth, uint96 shareOfEEth, bool isValid, uint32 feeGwei))",
67+
],
68+
runner,
69+
);
70+
71+
const statuses = await etherFiRequestStatuses(
72+
withdrawalNFT,
73+
[80636, 80641, 80648],
74+
);
75+
76+
assert.deepStrictEqual(selectClaimableEtherFiRequests(statuses), [80641]);
77+
}
78+
};
79+
80+
run()
81+
.then(() => console.log("etherfiQueue tests passed"))
82+
.catch((error) => {
83+
console.error(error);
84+
process.exitCode = 1;
85+
});

0 commit comments

Comments
 (0)