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: Add "discover addresses" using a permanode #7750

Merged
merged 12 commits into from
Dec 18, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
<Modal bind:this={modal} {...$$restProps}>
<account-actions-menu class="flex flex-col">
<MenuItem icon={Icon.Doc} title={localize('actions.viewBalanceBreakdown')} onClick={onViewBalanceClick} />
{#if $activeProfile?.network?.id === NetworkId.Iota}
{#if $activeProfile?.network?.id === NetworkId.Iota || $activeProfile?.network?.id === NetworkId.IotaAlphanet}
<MenuItem
icon={Icon.Timer}
title={localize('actions.viewAddressHistory')}
Expand Down
153 changes: 137 additions & 16 deletions packages/desktop/components/popups/AddressHistoryPopup.svelte
Original file line number Diff line number Diff line change
@@ -1,36 +1,146 @@
<script lang="ts">
import { getSelectedAccount } from '@core/account'
import { selectedAccount } from '@core/account'
import { handleError } from '@core/error/handlers/handleError'
import { localize } from '@core/i18n'
import { CHRONICLE_ADDRESS_HISTORY_ROUTE, CHRONICLE_URLS } from '@core/network/constants/chronicle-urls.constant'
import { fetchWithTimeout } from '@core/nfts'
import { checkActiveProfileAuth, getActiveProfile, updateAccountPersistedDataOnActiveProfile } from '@core/profile'
import { getProfileManager } from '@core/profile-manager/stores'
import { truncateString } from '@core/utils'
import { AccountAddress } from '@iota/sdk/out/types'
import VirtualList from '@sveltejs/svelte-virtual-list'
import { FontWeight, KeyValueBox, Spinner, Text, TextType } from 'shared/components'
import { Button, FontWeight, KeyValueBox, Spinner, Text, TextType } from 'shared/components'
import { onMount } from 'svelte'

let addressList: AccountAddress[] | undefined = undefined
interface AddressHistory {
address: string
items: [
{
milestoneIndex: number
milestoneTimestamp: number
outputId: string
isSpent: boolean
}
]
}

const activeProfile = getActiveProfile()
const ADDRESS_GAP_LIMIT = 20

let knownAddresses: AccountAddress[] = []

$: accountIndex = $selectedAccount?.index
$: network = activeProfile?.network?.id

let searchURL: string
let searchAddressStartIndex = 0
let currentSearchGap = 0
let isBusy = false

onMount(() => {
getSelectedAccount()
?.addresses()
.then((_addressList) => {
addressList = _addressList?.sort((a, b) => a.keyIndex - b.keyIndex) ?? []
})
.catch((err) => {
console.error(err)
addressList = []
})
knownAddresses = $selectedAccount?.knownAddresses
if (!knownAddresses?.length) {
isBusy = true
$selectedAccount
.addresses()
.then((_knownAddresses) => {
knownAddresses = sortAddresses(_knownAddresses)
updateAccountPersistedDataOnActiveProfile(accountIndex, { knownAddresses })
isBusy = false
})
.finally(() => {
isBusy = false
})
}

if (CHRONICLE_URLS[network] && CHRONICLE_URLS[network].length > 0) {
const chronicleRoot = CHRONICLE_URLS[network][0]
searchURL = `${chronicleRoot}${CHRONICLE_ADDRESS_HISTORY_ROUTE}`
} else {
throw new Error(localize('popups.addressHistory.errorNoChronicle'))
}
})

async function isAddressWithHistory(address: string): Promise<boolean> {
try {
const response = await fetchWithTimeout(`${searchURL}${address}`, 3, { method: 'GET' })
const addressHistory: AddressHistory = await response.json()
return addressHistory?.items?.length > 0
} catch (err) {
throw new Error(localize('popups.addressHistory.errorFailedFetch'))
}
}

async function generateNextUnknownAddress(): Promise<[string, number]> {
let nextUnknownAddress: string
try {
do {
nextUnknownAddress = await getProfileManager().generateEd25519Address(
accountIndex,
searchAddressStartIndex
)

searchAddressStartIndex++
} while (knownAddresses.map((accountAddress) => accountAddress.address).includes(nextUnknownAddress))
} catch (err) {
throw new Error(localize('popups.addressHistory.errorFailedGenerate'))
}

return [nextUnknownAddress, searchAddressStartIndex - 1]
}

async function search(): Promise<void> {
currentSearchGap = 0
const tmpKnownAddresses = [...knownAddresses]
while (currentSearchGap < ADDRESS_GAP_LIMIT) {
const [nextAddressToCheck, addressIndex] = await generateNextUnknownAddress()
if (!nextAddressToCheck) {
isBusy = false
break
}

const hasHistory = await isAddressWithHistory(nextAddressToCheck)
if (hasHistory) {
const accountAddress: AccountAddress = {
address: nextAddressToCheck,
keyIndex: addressIndex,
internal: false,
used: true,
}

tmpKnownAddresses.push(accountAddress)
} else {
currentSearchGap++
}
}
knownAddresses = sortAddresses(tmpKnownAddresses)
updateAccountPersistedDataOnActiveProfile(accountIndex, { knownAddresses })
}

async function handleSearchClick(): Promise<void> {
isBusy = true
try {
await checkActiveProfileAuth(search, { stronghold: true, ledger: true })
} catch (err) {
handleError(err)
} finally {
isBusy = false
}
}

function sortAddresses(addresses: AccountAddress[] = []): AccountAddress[] {
return addresses.sort((a, b) => a.keyIndex - b.keyIndex)
}
</script>

<div class="flex flex-col space-y-6">
<Text type={TextType.h3} fontWeight={FontWeight.semibold} lineHeight="6">
{localize('popups.addressHistory.title')}
</Text>
<Text fontSize="15" color="gray-700" classes="text-left">{localize('popups.addressHistory.disclaimer')}</Text>
{#if addressList}
{#if addressList.length > 0}
{#if knownAddresses}
{#if knownAddresses.length > 0}
<div class="w-full flex-col space-y-2 virtual-list-wrapper">
<VirtualList items={addressList} let:item>
<VirtualList items={knownAddresses} let:item>
<div class="mb-1">
<KeyValueBox
isCopyable
Expand Down Expand Up @@ -58,6 +168,17 @@
</div>
{/if}
</div>
<div class="flex flex-row flex-nowrap w-full space-x-4 mt-6">
<Button
classes="w-full"
onClick={handleSearchClick}
disabled={isBusy}
{isBusy}
busyMessage={localize('actions.searching')}
>
{localize('actions.search')}
</Button>
</div>

<style lang="scss">
.virtual-list-wrapper :global(svelte-virtual-list-viewport) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ export async function buildAccountStateAndPersistedData(
color?: string
): Promise<[IAccountState, IPersistedAccountData]> {
const { index } = account.getMetadata()
const knownAddresses = await account.addresses()
const persistedAccountData: IPersistedAccountData = {
name: name || `${localize('general.account')} ${index + 1}`,
color: color || getRandomAccountColor(),
hidden: false,
shouldRevote: false,
knownAddresses,
}
const accountState = await buildAccountState(account, persistedAccountData)
return [accountState, persistedAccountData]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ParticipationEventId } from '@iota/sdk/out/types'
import { AccountAddress, ParticipationEventId } from '@iota/sdk/out/types'

export interface IPersistedAccountData {
name: string
color: string
hidden: boolean
shouldRevote: boolean
removedProposalIds?: ParticipationEventId[]
knownAddresses: AccountAddress[]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NetworkId } from '../enums'

export const CHRONICLE_URLS: Readonly<{ [key in NetworkId]?: string[] }> = {
[NetworkId.Iota]: ['https://chronicle.stardust-mainnet.iotaledger.net/'],
[NetworkId.IotaAlphanet]: ['https://chronicle.alphanet.iotaledger.net/'],
[NetworkId.Shimmer]: ['https://chronicle.shimmer.network/'],
[NetworkId.Testnet]: ['https://chronicle.testnet.shimmer.network/'],
}

export const CHRONICLE_ADDRESS_HISTORY_ROUTE = 'api/explorer/v2/ledger/updates/by-address/'
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function checkAndMigrateChrysalisProfiles(): boolean {
color: account.color,
hidden: chrysalisProfile.hiddenAccounts?.includes(account.id) ?? false,
shouldRevote: false,
knownAddresses: [],
}

if (chrysalisProfile.lastUsedAccountId === account.id) {
Expand Down
6 changes: 4 additions & 2 deletions packages/shared/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1185,9 +1185,11 @@
},
"addressHistory": {
"title": "Address history",
"disclaimer": "List of addresses with funds or known by this profile",
"indexAndType": "{internal, select, true {Internal address} other {Deposit address}} {index}",
"internal": "Internal"
"internal": "Internal",
"errorNoChronicle": "Chronicle not configured",
"errorFailedFetch": "Couldn't fetch address history",
"errorFailedGenerate": "Couldn't generate a new address"
}
},
"charts": {
Expand Down