Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow l2 withdrawal (funds stuck in l2 that didnt reach evm) #7734

Merged
merged 17 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/desktop/components/modals/AccountActionsMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
modal?.close()
}

function onWithdrawFromL2Click(): void {
openPopup({ id: PopupId.WithdrawFromL2 })
modal?.close()
}

function onVerifyAddressClick(): void {
const ADDRESS_INDEX = 0
checkOrConnectLedger(() => {
Expand Down Expand Up @@ -79,6 +84,9 @@
onClick={onViewAddressHistoryClick}
/>
{/if}
{#if $activeProfile?.network?.id === NetworkId.Shimmer || $activeProfile?.network?.id === NetworkId.Testnet}
<MenuItem icon={Icon.Transfer} title={localize('actions.withdrawFromL2')} onClick={onWithdrawFromL2Click} />
{/if}
<MenuItem icon={Icon.Customize} title={localize('actions.customizeAcount')} onClick={onCustomiseAccountClick} />
{#if $isActiveLedgerProfile}
<MenuItem
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/components/popups/Popup.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import VestingCollectPopup from './VestingCollectPopup.svelte'
import PayoutDetailsPopup from './PayoutDetailsPopup.svelte'
import VestingRewardsFinderPopup from './VestingRewardsFinderPopup.svelte'
import WithdrawFromL2Popup from './WithdrawFromL2Popup.svelte'

export let id: PopupId
export let props: any
Expand Down Expand Up @@ -144,6 +145,7 @@
[PopupId.VestingCollect]: VestingCollectPopup,
[PopupId.PayoutDetails]: PayoutDetailsPopup,
[PopupId.VestingRewardsFinder]: VestingRewardsFinderPopup,
[PopupId.WithdrawFromL2]: WithdrawFromL2Popup,
}

function onKey(event: KeyboardEvent): void {
Expand Down
219 changes: 219 additions & 0 deletions packages/desktop/components/popups/WithdrawFromL2Popup.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<script lang="ts">
import { PopupId, closePopup, openPopup } from '@auxiliary/popup'
import { getSelectedAccount } from '@core/account'
import { localize } from '@core/i18n'
import { getArchivedBaseTokens } from '@core/layer-2/helpers/getArchivedBaseTokens'
import { getBaseToken, getCoinType, activeProfile, isActiveLedgerProfile, isSoftwareProfile } from '@core/profile'
import { truncateString } from '@core/utils'
import { formatTokenAmountPrecise, getRequiredStorageDepositForMinimalBasicOutput } from '@core/wallet'
import { Button, FontWeight, KeyValueBox, Spinner, Text, TextType } from 'shared/components'
import { onMount } from 'svelte'
import { WithdrawRequest, getLayer2WithdrawRequest } from '@core/layer-2/utils'
import { withdrawL2Funds } from '@core/layer-2/helpers/widthdrawL2Funds'
import { getL2ReceiptByRequestId } from '@core/layer-2/helpers/getL2ReceiptByRequestId'
import { showAppNotification } from '@auxiliary/notification'
import { Bip44 } from '@iota/sdk/out/types'
import { displayNotificationForLedgerProfile, ledgerNanoStatus } from '@core/ledger'
import { getEstimatedGasForOffLedgerRequest, getNonceForWithdrawRequest } from '@core/layer-2/helpers'

export let withdrawOnLoad = false
export let withdrawableAmount: number
const WASP_ISC_OPTIMIZATION_AMOUNT = 1
const WASP_ISC_MOCK_GAS_AMOUNT = 1000

const bip44Chain: Bip44 = {
coinType: Number(getCoinType()),
account: getSelectedAccount().index,
change: 0,
addressIndex: 0,
}

let error = ''
let address: string | undefined = undefined
let isWithdrawing = false
const { isStrongholdLocked } = $activeProfile

$: withdrawOnLoad && address && !$isStrongholdLocked && withdrawFromL2()

function onCancelClick(): void {
closePopup()
}

async function onWithdrawFromL2Click(): Promise<void> {
if ($isSoftwareProfile && $isStrongholdLocked) {
openUnlockStrongholdPopup()
} else {
await handleAction(withdrawFromL2)
}
}

async function handleAction(callback: () => Promise<void>): Promise<void> {
try {
error = ''

if ($isActiveLedgerProfile && !$ledgerNanoStatus.connected) {
displayNotificationForLedgerProfile('warning')
return
}

await callback()
} catch (err) {
error = localize(err.error)

if ($isActiveLedgerProfile) {
displayNotificationForLedgerProfile('error', true, true, err)
} else {
showAppNotification({
type: 'error',
message: localize(err.error),
})
}
}
}

async function withdrawFromL2(): Promise<void> {
isWithdrawing = true
if ($isActiveLedgerProfile && !$ledgerNanoStatus.connected) {
isWithdrawing = false
displayNotificationForLedgerProfile('warning')
return
}
try {
const nonce = await getNonceForWithdrawRequest(address)
if (!nonce) {
isWithdrawing = false
displayNotificationForLedgerProfile('warning')
return
}

const minRequiredStorageDeposit: number = Number(await getRequiredStorageDepositForMinimalBasicOutput())
let withdrawAmount =
withdrawableAmount < minRequiredStorageDeposit + WASP_ISC_MOCK_GAS_AMOUNT
? withdrawableAmount
: withdrawableAmount - WASP_ISC_MOCK_GAS_AMOUNT

// create withdraw request for gas estimations with hardcoded gasBudget
let withdrawRequest: WithdrawRequest | undefined
withdrawRequest = await getLayer2WithdrawRequest(withdrawAmount, nonce, bip44Chain)
const gasEstimatePayload = await getEstimatedGasForOffLedgerRequest(withdrawRequest.request)

// adjust withdrawAmount to use estimated gas fee charged
withdrawAmount = withdrawableAmount - gasEstimatePayload.gasFeeCharged
// calculate gas
const gasBudget = gasEstimatePayload.gasBurned + WASP_ISC_OPTIMIZATION_AMOUNT

if (withdrawableAmount > Number(minRequiredStorageDeposit) + Number(gasBudget)) {
// Create new withdraw request with correct gas budget and withdraw amount
withdrawRequest = await getLayer2WithdrawRequest(withdrawAmount, nonce, bip44Chain, gasBudget)
} else {
isWithdrawing = false
showErrorNotification(localize('error.send.notEnoughBalance'))
return
}

await withdrawL2Funds(withdrawRequest.request)
const receipt = await getL2ReceiptByRequestId(withdrawRequest.requestId)

isWithdrawing = false
if (receipt?.errorMessage) {
// if withdawing fails refresh the withdrawable amount because gas was used for the withdraw attempt
withdrawableAmount = await getArchivedBaseTokens(address)
showErrorNotification(receipt?.errorMessage)
} else {
closePopup()
}
} catch (err) {
// if withdawing fails refresh the withdrawable amount because gas was used for the withdraw attempt (withdrawL2Funds())
withdrawableAmount = await getArchivedBaseTokens(address)
let error = err
// TODO: check error object in real ledger device when user cancels transaction. (In simulator the returned object is a string)
// parse the error because ledger simulator returns error as a string.
if (typeof err === 'string') {
try {
const parsedError = JSON.parse(err)
error = parsedError?.payload ? parsedError.payload : parsedError
} catch (e) {
console.error(e)
}
}
isWithdrawing = false
showErrorNotification(error)
}
}

function openUnlockStrongholdPopup(): void {
openPopup({
id: PopupId.UnlockStronghold,
props: {
onSuccess: () => {
openPopup({
id: PopupId.WithdrawFromL2,
props: {
withdrawOnLoad: true,
withdrawableAmount,
},
})
},
onCancelled: () => {
openPopup({
id: PopupId.WithdrawFromL2,
props: {
withdrawableAmount,
},
})
},
subtitle: localize('popups.password.backup'),
},
})
}
function showErrorNotification(error): void {
if ($isActiveLedgerProfile) {
displayNotificationForLedgerProfile('error', true, false, error)
} else {
showAppNotification({
type: 'error',
message: error,
alert: true,
})
}
}

onMount(async () => {
address = getSelectedAccount().depositAddress
if (!withdrawableAmount) {
withdrawableAmount = await getArchivedBaseTokens(address)
}
})
</script>

<div class="flex flex-col space-y-6">
<Text type={TextType.h3} fontWeight={FontWeight.semibold} lineHeight="6">
{localize('popups.withdrawFromL2.title')}
</Text>
<Text fontSize="15" color="gray-700" classes="text-left">{localize('popups.withdrawFromL2.body')}</Text>
{#if address}
<KeyValueBox
classes="flex items-center w-full py-4"
keyText={truncateString(address, 15, 15)}
valueText={formatTokenAmountPrecise(withdrawableAmount, getBaseToken())}
/>
{:else}
<div class="flex items-center justify-center">
<Spinner />
</div>
{/if}
<div class="flex flex-row flex-nowrap w-full space-x-4 mt-6">
<Button classes="w-full" outline onClick={onCancelClick} disabled={isWithdrawing}>
{localize('actions.cancel')}
</Button>
<Button
classes="w-full"
onClick={onWithdrawFromL2Click}
disabled={!withdrawableAmount || Number(withdrawableAmount) === 0 || isWithdrawing}
isBusy={isWithdrawing}
busyMessage={localize('popups.withdrawFromL2.withdrawing')}
>
{localize('popups.withdrawFromL2.withdraw')}
</Button>
</div>
</div>
7 changes: 7 additions & 0 deletions packages/desktop/electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ try {

return client
},
async getSecretManager(managerId) {
const manager = profileManagers[managerId]
const secretManager = await manager.getSecretManager()
bindMethodsAcrossContextBridge(IotaSdk.SecretManager.prototype, secretManager)

return secretManager
},
async migrateStrongholdSnapshotV2ToV3(currentPath, newPath, currentPassword, newPassword) {
const snapshotSaltV2 = 'wallet.rs'
const snapshotRoundsV2 = 100
Expand Down
1 change: 1 addition & 0 deletions packages/shared/lib/auxiliary/popup/enums/popup-id.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ export enum PopupId {
VestingCollect = 'vestingCollect',
PayoutDetails = 'payoutDetails',
VestingRewardsFinder = 'vestingRewardsFinder',
WithdrawFromL2 = 'withdrawFromL2',
}
1 change: 1 addition & 0 deletions packages/shared/lib/core/layer-2/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './isc-magic-contract-address.constant'
export * from './layer2-tokens-poll-interval.constant'
export * from './target-contracts.constant'
export * from './transfer-allowance.constant'
export * from './withdraw.constant'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const WITHDRAW = 0x9dcc0f41
28 changes: 28 additions & 0 deletions packages/shared/lib/core/layer-2/helpers/getArchivedBaseTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { get } from 'svelte/store'
import { activeProfile } from '@core/profile'
import { DEFAULT_CHAIN_CONFIGURATIONS } from '@core/network'

interface ArchivedResponse {
baseTokens: number
}

export async function getArchivedBaseTokens(address: string): Promise<number> {
const defaultChainConfig = DEFAULT_CHAIN_CONFIGURATIONS[get(activeProfile)?.network?.id]
const URL = `${defaultChainConfig?.archiveEndpoint}/v1/chains/${defaultChainConfig?.aliasAddress}/core/accounts/account/${address}/balance`

try {
const archivedResponse: ArchivedResponse = await fetch(URL).then((response) => {
if (response.status >= 400) {
return response.json().then((err) => {
throw new Error(`Message: ${err.Message}, Error: ${err.Error}`)
})
}

return response.json()
})

return archivedResponse.baseTokens
} catch (_) {
return 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { get } from 'svelte/store'
import { activeProfile } from '@core/profile'
import { DEFAULT_CHAIN_CONFIGURATIONS } from '@core/network'
import BigInteger from 'big-integer'
import { HexEncodedString } from '@iota/sdk'

interface GasEstimatePayload {
gasBurned?: number
gasFeeCharged?: number
}

export async function getEstimatedGasForOffLedgerRequest(requestHex: HexEncodedString): Promise<GasEstimatePayload> {
const defaultChainConfig = DEFAULT_CHAIN_CONFIGURATIONS[get(activeProfile)?.network?.id]
const URL = `${defaultChainConfig?.archiveEndpoint}/v1/chains/${defaultChainConfig?.aliasAddress}/estimategas-offledger`

const requestOptions = {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
requestBytes: requestHex,
}),
}

try {
const response = await fetch(URL, requestOptions)

if (response.status >= 400) {
return response.json().then((err) => {
throw new Error(`Message: ${err.Message}, Error: ${err.Error}`)
})
}

if (response.status === 200) {
const data = await response.json()
if (data.errorMessage) {
throw new Error(data.errorMessage)
}
const gasBurned = BigInteger(data.gasBurned as string).toJSNumber()
const gasFeeCharged = BigInteger(data.gasFeeCharged as string).toJSNumber()

return { gasBurned, gasFeeCharged }
}
} catch (error) {
console.error(error)
throw new Error(error.message)
}
return {}
}
Loading
Loading