Skip to content

Commit 624eb58

Browse files
committed
Fix unstake queue fetching
1 parent 30ee704 commit 624eb58

File tree

5 files changed

+54
-281
lines changed

5 files changed

+54
-281
lines changed

packages/ethereum/src/lib/methods/buildWithdrawTx.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export async function buildWithdrawTx (request: {
2424
return positionTickets.includes(item.positionTicket.toString())
2525
})
2626
.map((item) => {
27-
const timestamp = Math.floor(item.when.getTime() / 1000)
27+
const timestamp = item.timestamp / 1000
2828
if (item.exitQueueIndex === undefined) {
2929
throw new Error('Exit queue index is missing')
3030
}
Original file line numberDiff line numberDiff line change
@@ -1,144 +1,57 @@
11
import { Hex } from 'viem'
22
import { StakewiseConnector } from '../connector'
3-
import { VaultABI } from '../contracts/vaultAbi'
4-
import { UnstakeQueueItem } from '../types/unstakeQueue'
53

64
export const getUnstakeQueue = async (params: { connector: StakewiseConnector; userAccount: Hex; vault: Hex }) => {
75
const { connector, vault, userAccount } = params
86
const queueData = await connector.graphqlRequest({
97
type: 'graph',
108
op: 'exitQueue',
119
query: `
12-
query exitQueue($receiver: Bytes, $vault: String!) {
13-
exitRequests(where: {
14-
receiver: $receiver,
15-
vault: $vault,
16-
}) {
17-
positionTicket
18-
totalShares
10+
query exitQueue($where: ExitRequest_filter) {
11+
exitRequests(where: $where) {
12+
receiver
13+
isClaimed
1914
timestamp
15+
totalAssets
16+
isClaimed
17+
isClaimable
18+
exitedAssets
19+
isV2Position
20+
positionTicket
21+
exitQueueIndex
22+
withdrawalTimestamp
2023
}
2124
}
2225
`,
2326
variables: {
24-
vault: vault.toLowerCase(),
25-
receiver: userAccount
27+
where: {
28+
vault: vault.toLowerCase(),
29+
receiver: userAccount.toLowerCase()
30+
}
2631
}
2732
})
2833

2934
if (!queueData.data.exitRequests) {
3035
throw new Error('Queue data is missing the exitRequests field')
3136
}
3237

33-
return parseQueueData({
34-
exitRequests: queueData.data.exitRequests.map((r: any) => ({
35-
positionTicket: BigInt(r.positionTicket),
36-
totalShares: BigInt(r.totalShares),
37-
when: new Date(Number(r.timestamp) * 1000)
38-
})),
39-
connector,
40-
userAddress: userAccount,
41-
vaultAddress: vault
42-
})
43-
}
44-
45-
const getIs24HoursPassed = async (when: Date, connector: StakewiseConnector) => {
46-
const lastBlock = await connector.eth.getBlock()
47-
48-
const current = lastBlock ? lastBlock.timestamp : Number((new Date().getTime() / 1000).toFixed(0))
49-
50-
const diff = Number(current) - Number(when.getTime() / 1000)
51-
52-
return diff > 86_400 // 24 hours
53-
}
54-
55-
type ParseQueueDataArgs = {
56-
connector: StakewiseConnector
57-
userAddress: Hex
58-
vaultAddress: Hex
59-
exitRequests: Array<{
60-
positionTicket: bigint
61-
totalShares: bigint
62-
when: Date
63-
}>
64-
}
65-
66-
const parseQueueData = async ({ connector, userAddress, vaultAddress, exitRequests }: ParseQueueDataArgs) => {
67-
const unstakeQueue = await Promise.all(
68-
exitRequests.map(async (exitRequest): Promise<UnstakeQueueItem> => {
69-
const { positionTicket, when, totalShares } = exitRequest
70-
71-
// We must fetch the exit queue index for every position.
72-
// Based on the response we can determine if we can claim exited assets.
73-
const baseExitQueueIndex = await connector.eth.readContract({
74-
abi: VaultABI,
75-
address: vaultAddress,
76-
functionName: 'getExitQueueIndex',
77-
args: [positionTicket]
78-
})
79-
80-
const exitQueueIndex = baseExitQueueIndex > -1n ? baseExitQueueIndex : undefined
81-
82-
// 24 hours must have elapsed since the position was created
83-
const is24HoursPassed = await getIs24HoursPassed(when, connector)
84-
85-
// If the index is -1 then we cannot claim anything. Otherwise, the value is >= 0.
86-
const isValid = exitQueueIndex !== undefined
87-
88-
const isWithdrawable = isValid && is24HoursPassed
89-
90-
const totalAssets = await connector.eth.readContract({
91-
abi: VaultABI,
92-
address: vaultAddress,
93-
functionName: 'convertToAssets',
94-
args: [totalShares]
95-
})
96-
97-
if (!isWithdrawable || exitQueueIndex === undefined) {
98-
const nonWithdrawable: UnstakeQueueItem = {
99-
exitQueueIndex,
100-
positionTicket,
101-
when,
102-
isWithdrawable: false,
103-
totalShares,
104-
totalAssets,
105-
leftShares: totalShares,
106-
leftAssets: totalAssets,
107-
withdrawableShares: 0n,
108-
withdrawableAssets: 0n
109-
}
110-
return nonWithdrawable
111-
}
112-
113-
const [leftShares, withdrawableShares, withdrawableAssets] = await connector.eth.readContract({
114-
abi: VaultABI,
115-
address: vaultAddress,
116-
functionName: 'calculateExitedAssets',
117-
args: [userAddress, positionTicket, BigInt(Number(when) / 1000), exitQueueIndex] as const
118-
})
119-
120-
const leftAssets = await connector.eth.readContract({
121-
abi: VaultABI,
122-
address: vaultAddress,
123-
functionName: 'convertToAssets',
124-
args: [leftShares]
125-
})
126-
127-
const fullPosition: UnstakeQueueItem = {
128-
positionTicket,
129-
when,
130-
totalShares,
131-
isWithdrawable: true,
132-
exitQueueIndex,
133-
totalAssets,
134-
leftShares,
135-
leftAssets,
136-
withdrawableShares,
137-
withdrawableAssets
138-
}
139-
return fullPosition
140-
})
141-
)
142-
143-
return unstakeQueue.sort((a, b) => Number(b.when) - Number(a.when))
38+
const data = queueData.data.exitRequests as {
39+
timestamp: string
40+
isClaimable: boolean
41+
isClaimed: boolean
42+
totalAssets: string
43+
exitedAssets: string
44+
positionTicket: string
45+
exitQueueIndex: string | null
46+
}[]
47+
48+
return data.map((queueItem) => ({
49+
positionTicket: BigInt(queueItem.positionTicket),
50+
exitQueueIndex: queueItem.exitQueueIndex ? BigInt(queueItem.exitQueueIndex) : undefined,
51+
isWithdrawable: queueItem.isClaimable,
52+
wasWithdrawn: queueItem.isClaimed,
53+
timestamp: Number(queueItem.timestamp) * 1000,
54+
totalAssets: BigInt(queueItem.totalAssets),
55+
exitedAssets: BigInt(queueItem.exitedAssets || 0)
56+
}))
14457
}

packages/ethereum/src/staker.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,11 @@ export class EthereumStaker {
346346

347347
return queue.map((item) => ({
348348
positionTicket: item.positionTicket.toString(),
349-
timestamp: item.when.getTime(),
349+
exitQueueIndex: item.exitQueueIndex?.toString(),
350+
timestamp: item.timestamp,
350351
isWithdrawable: item.isWithdrawable,
351352
totalAmount: formatEther(item.totalAssets),
352-
withdrawableAmount: formatEther(item.withdrawableAssets)
353+
withdrawableAmount: formatEther(item.totalAssets - item.exitedAssets)
353354
}))
354355
}
355356

+14-155
Original file line numberDiff line numberDiff line change
@@ -1,177 +1,36 @@
1-
import { Hex, PublicClient, WalletClient, decodeEventLog, formatEther, parseEther } from 'viem'
1+
import { Hex } from 'viem'
22
import { assert } from 'chai'
33
import { EthereumStaker } from '../dist/mjs'
4-
import { prepareTests, stake } from './lib/utils'
5-
import { VaultABI } from '../src/lib/contracts/vaultAbi'
6-
const amountToStake = parseEther('5')
7-
const amountToUnstake = parseEther('1')
8-
9-
const originalFetch = global.fetch
10-
11-
// https://github.com/tc39/proposal-promise-with-resolvers/blob/main/polyfills.js
12-
const withResolvers = <V = unknown, Err = unknown>() => {
13-
const out: {
14-
resolve: (value: V) => void
15-
reject: (reason: Err) => void
16-
promise: Promise<V>
17-
} = {
18-
resolve: () => {},
19-
reject: () => {},
20-
promise: Promise.resolve() as Promise<V>
21-
}
22-
23-
out.promise = new Promise<V>((resolve, reject) => {
24-
out.resolve = resolve
25-
out.reject = reject
26-
})
27-
28-
return out
29-
}
30-
31-
type VaultEvent = ReturnType<typeof decodeEventLog<typeof VaultABI, 'ExitQueueEntered'>>
4+
import { prepareTests } from './lib/utils'
325

336
describe('EthereumStaker.getUnstakeQueue', () => {
347
let delegatorAddress: Hex
358
let validatorAddress: Hex
36-
let walletClient: WalletClient
37-
let publicClient: PublicClient
389
let staker: EthereumStaker
39-
let unwatch: () => void = () => {}
40-
41-
const unstake = async (amount: string) => {
42-
const { tx } = await staker.buildUnstakeTx({
43-
delegatorAddress,
44-
validatorAddress,
45-
amount
46-
})
47-
48-
const request = await walletClient.prepareTransactionRequest({
49-
...tx,
50-
chain: undefined
51-
})
52-
const hash = await walletClient.sendTransaction({
53-
...request,
54-
account: delegatorAddress
55-
})
56-
57-
const receipt = await publicClient.waitForTransactionReceipt({ hash })
58-
assert.equal(receipt.status, 'success')
59-
}
6010

6111
beforeEach(async () => {
6212
const setup = await prepareTests()
63-
64-
delegatorAddress = setup.walletClient.account.address
13+
// Use stale delegator address which never unstaked
14+
delegatorAddress = '0x15e4287B086f0a8556A5B578a8d8284F19F2c9aC'
6515
validatorAddress = setup.validatorAddress
66-
publicClient = setup.publicClient
67-
walletClient = setup.walletClient
6816
staker = setup.staker
69-
70-
await stake({
71-
delegatorAddress,
72-
validatorAddress,
73-
amountToStake,
74-
publicClient,
75-
walletClient,
76-
staker
77-
})
78-
})
79-
80-
afterEach(() => {
81-
unwatch()
82-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
83-
// @ts-ignore
84-
global.fetch = originalFetch
8517
})
8618

8719
it('should return the unstake queue', async () => {
88-
// Subscribe to the ExitQueueEntered events
89-
const { resolve: eventsResolve, promise: eventsPromise } = withResolvers<VaultEvent[]>()
90-
const passedEvents: VaultEvent[] = []
91-
92-
unwatch = publicClient.watchEvent({
93-
onLogs: (logs) => {
94-
const nextEvents = logs
95-
.map((l) =>
96-
decodeEventLog({
97-
abi: VaultABI,
98-
data: l.data,
99-
topics: l.topics
100-
})
101-
)
102-
.filter((e): e is VaultEvent => e.eventName === 'ExitQueueEntered')
103-
passedEvents.push(...nextEvents)
104-
if (passedEvents.length === 2) {
105-
eventsResolve(passedEvents.sort((a, b) => Number(a.args.shares) - Number(b.args.shares)))
106-
}
107-
}
108-
})
109-
110-
// Unstake
111-
112-
await unstake(formatEther(amountToUnstake))
113-
await unstake('2')
114-
115-
// Wait for the events to be processed
116-
117-
const events = await eventsPromise
118-
119-
assert.strictEqual(events.length, 2)
120-
// The shares are not exactly the same as the amount to unstake
121-
assert.closeTo(Number(events[0].args.shares), Number(parseEther('1')), Number(parseEther('0.1')))
122-
assert.isTrue(typeof events[0].args.positionTicket === 'bigint')
123-
124-
// mock the request to Stakewise with positionTicket and totalShares from the events
125-
126-
const day = 24 * 60 * 60
127-
const mockExitRequests = [
128-
{
129-
positionTicket: events[0].args.positionTicket.toString(),
130-
totalShares: events[0].args.shares.toString(),
131-
// earlier
132-
timestamp: Math.round((new Date().getTime() - 60000) / 1000 - day * 2).toString()
133-
},
134-
{
135-
positionTicket: events[1].args.positionTicket.toString(),
136-
totalShares: events[1].args.shares.toString(),
137-
// later
138-
timestamp: Math.round(new Date().getTime() / 1000 - day * 2).toString()
139-
}
140-
]
141-
142-
const mockFetch = (input, init) => {
143-
if (input === 'https://holesky-graph.stakewise.io/subgraphs/name/stakewise/stakewise?opName=exitQueue') {
144-
return Promise.resolve({
145-
ok: true,
146-
json: () =>
147-
Promise.resolve({
148-
data: {
149-
exitRequests: mockExitRequests
150-
}
151-
})
152-
})
153-
} else {
154-
return originalFetch(input, init) // Fallback to the original fetch for other URLs
155-
}
156-
}
157-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
158-
// @ts-ignore
159-
global.fetch = mockFetch
160-
16120
const unstakeQueue = await staker.getUnstakeQueue({
16221
validatorAddress,
16322
delegatorAddress
16423
})
16524

166-
assert.strictEqual(unstakeQueue.length, 2)
167-
// The queue is sorted by the timestamp from latest to earliest
168-
const earlierItem = unstakeQueue[1]
169-
const earlierMock = mockExitRequests[0]
170-
171-
assert.equal(earlierItem.timestamp, new Date(Number(earlierMock.timestamp) * 1000).getTime())
172-
// Take into account 1 wei assets conversion issues on the contract
173-
assert.closeTo(Number(parseEther(earlierItem.totalAmount)), Number(amountToUnstake), 1)
174-
175-
assert.isFalse(earlierItem.isWithdrawable)
25+
assert.deepEqual(unstakeQueue, [
26+
{
27+
exitQueueIndex: '47',
28+
positionTicket: '112811942030831899448',
29+
timestamp: 1711727436000,
30+
isWithdrawable: true,
31+
totalAmount: '0.500019229644855834',
32+
withdrawableAmount: '0'
33+
}
34+
])
17635
})
17736
})

0 commit comments

Comments
 (0)