Skip to content

Commit 9c944b5

Browse files
marc2332brancoderbegonaalvarezd
authored
feat: allow l2 withdrawal (funds stuck in l2 that didnt reach evm) (#7734)
* feat: 1.5 layer withdrawal * feat: add withdraw from l2 popup * feat: add withdrawing L2 funds * fix: constants and unused functions * fix: code styling * fix: improve error handling and withdraw flow * fix: add signing of essence bytes with secret manager & update withdraw flow * fix: add mock withdraw request for gas estimate + ledger error notifications * fix: adjust the gas estimation requests and withdraw flow --------- Co-authored-by: Branko Bosnic <[email protected]> Co-authored-by: Begoña Álvarez de la Cruz <[email protected]>
1 parent 13a67a4 commit 9c944b5

19 files changed

+507
-1
lines changed

packages/desktop/components/modals/AccountActionsMenu.svelte

+8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
modal?.close()
3535
}
3636
37+
function onWithdrawFromL2Click(): void {
38+
openPopup({ id: PopupId.WithdrawFromL2 })
39+
modal?.close()
40+
}
41+
3742
function onVerifyAddressClick(): void {
3843
const ADDRESS_INDEX = 0
3944
checkOrConnectLedger(() => {
@@ -79,6 +84,9 @@
7984
onClick={onViewAddressHistoryClick}
8085
/>
8186
{/if}
87+
{#if $activeProfile?.network?.id === NetworkId.Shimmer || $activeProfile?.network?.id === NetworkId.Testnet}
88+
<MenuItem icon={Icon.Transfer} title={localize('actions.withdrawFromL2')} onClick={onWithdrawFromL2Click} />
89+
{/if}
8290
<MenuItem icon={Icon.Customize} title={localize('actions.customizeAcount')} onClick={onCustomiseAccountClick} />
8391
{#if $isActiveLedgerProfile}
8492
<MenuItem

packages/desktop/components/popups/Popup.svelte

+2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import VestingCollectPopup from './VestingCollectPopup.svelte'
5858
import PayoutDetailsPopup from './PayoutDetailsPopup.svelte'
5959
import VestingRewardsFinderPopup from './VestingRewardsFinderPopup.svelte'
60+
import WithdrawFromL2Popup from './WithdrawFromL2Popup.svelte'
6061
6162
export let id: PopupId
6263
export let props: any
@@ -144,6 +145,7 @@
144145
[PopupId.VestingCollect]: VestingCollectPopup,
145146
[PopupId.PayoutDetails]: PayoutDetailsPopup,
146147
[PopupId.VestingRewardsFinder]: VestingRewardsFinderPopup,
148+
[PopupId.WithdrawFromL2]: WithdrawFromL2Popup,
147149
}
148150
149151
function onKey(event: KeyboardEvent): void {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<script lang="ts">
2+
import { PopupId, closePopup, openPopup } from '@auxiliary/popup'
3+
import { getSelectedAccount } from '@core/account'
4+
import { localize } from '@core/i18n'
5+
import { getArchivedBaseTokens } from '@core/layer-2/helpers/getArchivedBaseTokens'
6+
import { getBaseToken, getCoinType, activeProfile, isActiveLedgerProfile, isSoftwareProfile } from '@core/profile'
7+
import { truncateString } from '@core/utils'
8+
import { formatTokenAmountPrecise, getRequiredStorageDepositForMinimalBasicOutput } from '@core/wallet'
9+
import { Button, FontWeight, KeyValueBox, Spinner, Text, TextType } from 'shared/components'
10+
import { onMount } from 'svelte'
11+
import { WithdrawRequest, getLayer2WithdrawRequest } from '@core/layer-2/utils'
12+
import { withdrawL2Funds } from '@core/layer-2/helpers/widthdrawL2Funds'
13+
import { getL2ReceiptByRequestId } from '@core/layer-2/helpers/getL2ReceiptByRequestId'
14+
import { showAppNotification } from '@auxiliary/notification'
15+
import { Bip44 } from '@iota/sdk/out/types'
16+
import { displayNotificationForLedgerProfile, ledgerNanoStatus } from '@core/ledger'
17+
import { getEstimatedGasForOffLedgerRequest, getNonceForWithdrawRequest } from '@core/layer-2/helpers'
18+
19+
export let withdrawOnLoad = false
20+
export let withdrawableAmount: number
21+
const WASP_ISC_OPTIMIZATION_AMOUNT = 1
22+
const WASP_ISC_MOCK_GAS_AMOUNT = 1000
23+
24+
const bip44Chain: Bip44 = {
25+
coinType: Number(getCoinType()),
26+
account: getSelectedAccount().index,
27+
change: 0,
28+
addressIndex: 0,
29+
}
30+
31+
let error = ''
32+
let address: string | undefined = undefined
33+
let isWithdrawing = false
34+
const { isStrongholdLocked } = $activeProfile
35+
36+
$: withdrawOnLoad && address && !$isStrongholdLocked && withdrawFromL2()
37+
38+
function onCancelClick(): void {
39+
closePopup()
40+
}
41+
42+
async function onWithdrawFromL2Click(): Promise<void> {
43+
if ($isSoftwareProfile && $isStrongholdLocked) {
44+
openUnlockStrongholdPopup()
45+
} else {
46+
await handleAction(withdrawFromL2)
47+
}
48+
}
49+
50+
async function handleAction(callback: () => Promise<void>): Promise<void> {
51+
try {
52+
error = ''
53+
54+
if ($isActiveLedgerProfile && !$ledgerNanoStatus.connected) {
55+
displayNotificationForLedgerProfile('warning')
56+
return
57+
}
58+
59+
await callback()
60+
} catch (err) {
61+
error = localize(err.error)
62+
63+
if ($isActiveLedgerProfile) {
64+
displayNotificationForLedgerProfile('error', true, true, err)
65+
} else {
66+
showAppNotification({
67+
type: 'error',
68+
message: localize(err.error),
69+
})
70+
}
71+
}
72+
}
73+
74+
async function withdrawFromL2(): Promise<void> {
75+
isWithdrawing = true
76+
if ($isActiveLedgerProfile && !$ledgerNanoStatus.connected) {
77+
isWithdrawing = false
78+
displayNotificationForLedgerProfile('warning')
79+
return
80+
}
81+
try {
82+
const nonce = await getNonceForWithdrawRequest(address)
83+
if (!nonce) {
84+
isWithdrawing = false
85+
displayNotificationForLedgerProfile('warning')
86+
return
87+
}
88+
89+
const minRequiredStorageDeposit: number = Number(await getRequiredStorageDepositForMinimalBasicOutput())
90+
let withdrawAmount =
91+
withdrawableAmount < minRequiredStorageDeposit + WASP_ISC_MOCK_GAS_AMOUNT
92+
? withdrawableAmount
93+
: withdrawableAmount - WASP_ISC_MOCK_GAS_AMOUNT
94+
95+
// create withdraw request for gas estimations with hardcoded gasBudget
96+
let withdrawRequest: WithdrawRequest | undefined
97+
withdrawRequest = await getLayer2WithdrawRequest(withdrawAmount, nonce, bip44Chain)
98+
const gasEstimatePayload = await getEstimatedGasForOffLedgerRequest(withdrawRequest.request)
99+
100+
// adjust withdrawAmount to use estimated gas fee charged
101+
withdrawAmount = withdrawableAmount - gasEstimatePayload.gasFeeCharged
102+
// calculate gas
103+
const gasBudget = gasEstimatePayload.gasBurned + WASP_ISC_OPTIMIZATION_AMOUNT
104+
105+
if (withdrawableAmount > Number(minRequiredStorageDeposit) + Number(gasBudget)) {
106+
// Create new withdraw request with correct gas budget and withdraw amount
107+
withdrawRequest = await getLayer2WithdrawRequest(withdrawAmount, nonce, bip44Chain, gasBudget)
108+
} else {
109+
isWithdrawing = false
110+
showErrorNotification(localize('error.send.notEnoughBalance'))
111+
return
112+
}
113+
114+
await withdrawL2Funds(withdrawRequest.request)
115+
const receipt = await getL2ReceiptByRequestId(withdrawRequest.requestId)
116+
117+
isWithdrawing = false
118+
if (receipt?.errorMessage) {
119+
// if withdawing fails refresh the withdrawable amount because gas was used for the withdraw attempt
120+
withdrawableAmount = await getArchivedBaseTokens(address)
121+
showErrorNotification(receipt?.errorMessage)
122+
} else {
123+
closePopup()
124+
}
125+
} catch (err) {
126+
// if withdawing fails refresh the withdrawable amount because gas was used for the withdraw attempt (withdrawL2Funds())
127+
withdrawableAmount = await getArchivedBaseTokens(address)
128+
let error = err
129+
// TODO: check error object in real ledger device when user cancels transaction. (In simulator the returned object is a string)
130+
// parse the error because ledger simulator returns error as a string.
131+
if (typeof err === 'string') {
132+
try {
133+
const parsedError = JSON.parse(err)
134+
error = parsedError?.payload ? parsedError.payload : parsedError
135+
} catch (e) {
136+
console.error(e)
137+
}
138+
}
139+
isWithdrawing = false
140+
showErrorNotification(error)
141+
}
142+
}
143+
144+
function openUnlockStrongholdPopup(): void {
145+
openPopup({
146+
id: PopupId.UnlockStronghold,
147+
props: {
148+
onSuccess: () => {
149+
openPopup({
150+
id: PopupId.WithdrawFromL2,
151+
props: {
152+
withdrawOnLoad: true,
153+
withdrawableAmount,
154+
},
155+
})
156+
},
157+
onCancelled: () => {
158+
openPopup({
159+
id: PopupId.WithdrawFromL2,
160+
props: {
161+
withdrawableAmount,
162+
},
163+
})
164+
},
165+
subtitle: localize('popups.password.backup'),
166+
},
167+
})
168+
}
169+
function showErrorNotification(error): void {
170+
if ($isActiveLedgerProfile) {
171+
displayNotificationForLedgerProfile('error', true, false, error)
172+
} else {
173+
showAppNotification({
174+
type: 'error',
175+
message: error,
176+
alert: true,
177+
})
178+
}
179+
}
180+
181+
onMount(async () => {
182+
address = getSelectedAccount().depositAddress
183+
if (!withdrawableAmount) {
184+
withdrawableAmount = await getArchivedBaseTokens(address)
185+
}
186+
})
187+
</script>
188+
189+
<div class="flex flex-col space-y-6">
190+
<Text type={TextType.h3} fontWeight={FontWeight.semibold} lineHeight="6">
191+
{localize('popups.withdrawFromL2.title')}
192+
</Text>
193+
<Text fontSize="15" color="gray-700" classes="text-left">{localize('popups.withdrawFromL2.body')}</Text>
194+
{#if address}
195+
<KeyValueBox
196+
classes="flex items-center w-full py-4"
197+
keyText={truncateString(address, 15, 15)}
198+
valueText={formatTokenAmountPrecise(withdrawableAmount, getBaseToken())}
199+
/>
200+
{:else}
201+
<div class="flex items-center justify-center">
202+
<Spinner />
203+
</div>
204+
{/if}
205+
<div class="flex flex-row flex-nowrap w-full space-x-4 mt-6">
206+
<Button classes="w-full" outline onClick={onCancelClick} disabled={isWithdrawing}>
207+
{localize('actions.cancel')}
208+
</Button>
209+
<Button
210+
classes="w-full"
211+
onClick={onWithdrawFromL2Click}
212+
disabled={!withdrawableAmount || Number(withdrawableAmount) === 0 || isWithdrawing}
213+
isBusy={isWithdrawing}
214+
busyMessage={localize('popups.withdrawFromL2.withdrawing')}
215+
>
216+
{localize('popups.withdrawFromL2.withdraw')}
217+
</Button>
218+
</div>
219+
</div>

packages/desktop/electron/preload.js

+7
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ try {
163163

164164
return client
165165
},
166+
async getSecretManager(managerId) {
167+
const manager = profileManagers[managerId]
168+
const secretManager = await manager.getSecretManager()
169+
bindMethodsAcrossContextBridge(IotaSdk.SecretManager.prototype, secretManager)
170+
171+
return secretManager
172+
},
166173
async migrateStrongholdSnapshotV2ToV3(currentPath, newPath, currentPassword, newPassword) {
167174
const snapshotSaltV2 = 'wallet.rs'
168175
const snapshotRoundsV2 = 100

packages/shared/lib/auxiliary/popup/enums/popup-id.enum.ts

+1
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,5 @@ export enum PopupId {
4949
VestingCollect = 'vestingCollect',
5050
PayoutDetails = 'payoutDetails',
5151
VestingRewardsFinder = 'vestingRewardsFinder',
52+
WithdrawFromL2 = 'withdrawFromL2',
5253
}

packages/shared/lib/core/layer-2/constants/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './isc-magic-contract-address.constant'
1010
export * from './layer2-tokens-poll-interval.constant'
1111
export * from './target-contracts.constant'
1212
export * from './transfer-allowance.constant'
13+
export * from './withdraw.constant'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const WITHDRAW = 0x9dcc0f41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { get } from 'svelte/store'
2+
import { activeProfile } from '@core/profile'
3+
import { DEFAULT_CHAIN_CONFIGURATIONS } from '@core/network'
4+
5+
interface ArchivedResponse {
6+
baseTokens: number
7+
}
8+
9+
export async function getArchivedBaseTokens(address: string): Promise<number> {
10+
const defaultChainConfig = DEFAULT_CHAIN_CONFIGURATIONS[get(activeProfile)?.network?.id]
11+
const URL = `${defaultChainConfig?.archiveEndpoint}/v1/chains/${defaultChainConfig?.aliasAddress}/core/accounts/account/${address}/balance`
12+
13+
try {
14+
const archivedResponse: ArchivedResponse = await fetch(URL).then((response) => {
15+
if (response.status >= 400) {
16+
return response.json().then((err) => {
17+
throw new Error(`Message: ${err.Message}, Error: ${err.Error}`)
18+
})
19+
}
20+
21+
return response.json()
22+
})
23+
24+
return archivedResponse.baseTokens
25+
} catch (_) {
26+
return 0
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { get } from 'svelte/store'
2+
import { activeProfile } from '@core/profile'
3+
import { DEFAULT_CHAIN_CONFIGURATIONS } from '@core/network'
4+
import BigInteger from 'big-integer'
5+
import { HexEncodedString } from '@iota/sdk'
6+
7+
interface GasEstimatePayload {
8+
gasBurned?: number
9+
gasFeeCharged?: number
10+
}
11+
12+
export async function getEstimatedGasForOffLedgerRequest(requestHex: HexEncodedString): Promise<GasEstimatePayload> {
13+
const defaultChainConfig = DEFAULT_CHAIN_CONFIGURATIONS[get(activeProfile)?.network?.id]
14+
const URL = `${defaultChainConfig?.archiveEndpoint}/v1/chains/${defaultChainConfig?.aliasAddress}/estimategas-offledger`
15+
16+
const requestOptions = {
17+
method: 'POST',
18+
headers: {
19+
Accept: 'application/json',
20+
'Content-Type': 'application/json',
21+
},
22+
body: JSON.stringify({
23+
requestBytes: requestHex,
24+
}),
25+
}
26+
27+
try {
28+
const response = await fetch(URL, requestOptions)
29+
30+
if (response.status >= 400) {
31+
return response.json().then((err) => {
32+
throw new Error(`Message: ${err.Message}, Error: ${err.Error}`)
33+
})
34+
}
35+
36+
if (response.status === 200) {
37+
const data = await response.json()
38+
if (data.errorMessage) {
39+
throw new Error(data.errorMessage)
40+
}
41+
const gasBurned = BigInteger(data.gasBurned as string).toJSNumber()
42+
const gasFeeCharged = BigInteger(data.gasFeeCharged as string).toJSNumber()
43+
44+
return { gasBurned, gasFeeCharged }
45+
}
46+
} catch (error) {
47+
console.error(error)
48+
throw new Error(error.message)
49+
}
50+
return {}
51+
}

0 commit comments

Comments
 (0)