Skip to content
Draft
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
3 changes: 2 additions & 1 deletion docs/ai/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,5 @@ Only Plug's **Internet Computer identity** is derived from the seed phrase. Plug
- **Balances shown.** Every enabled fungible token the import can read: ICP and ICRC by principal, ERC20 by contract on each supported EVM network, SPL by associated token account, and the native coin on Bitcoin, each EVM network, and Solana. Balances of zero are hidden — the page exists to show what there is to move — while a balance that could not be loaded is shown as **unavailable**, so an unreachable provider never looks like an empty account. Tokens that can **never** be read are left out altogether rather than shown as unavailable: DIP20 tokens expose no ICRC balance method, and a few ICRC ledgers have been uninstalled entirely. A balance that merely failed this time keeps its row, and the page explains what **unavailable** means — so a passing network problem is never mistaken for an empty wallet.
- **Dogecoin and Litecoin are not shown.** Plug supports both and OISY supports neither. The page says so explicitly rather than omitting them silently, so a user with holdings there does not conclude the import is broken.
- **Internet Computer tokens can be moved.** Each ICP or ICRC row offers a **Send to my wallet** action that transfers the balance to the user's own OISY account, signed locally with the identity the seed phrase controls. The network fee is deducted from the same token, so the amount sent is the balance minus the fee, and a balance that cannot cover its own fee shows the reason instead of an action that could only fail. Every send is confirmed first, since it cannot be undone, and only one runs at a time.
- **Other networks are send-from-source.** Bitcoin, Ethereum, the other EVM networks and Solana cannot be signed by OISY — those keys live in the original wallet's canister — so the page says to send from there to the user's OISY addresses.
- **EVM balances can be moved too.** Ethereum and the other EVM networks are signed by the original wallet's own canister — the only party that can sign for those addresses — and OISY broadcasts the result. Gas is paid in the network's native coin out of the imported account, so a native transfer moves the balance minus gas, and a token transfer needs native coin sitting in that same account; a row that lacks it says which coin is missing rather than simply greying out.
- **Bitcoin and Solana are still send-from-source.** Their transfers are not built yet, so the page says to send them from the original wallet to the user's OISY addresses.
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,31 @@ Sign locally with the `Secp256k1KeyIdentity` derived from the phrase and transfe

**Irreversibility is confirmed.** Each send goes through the shared `ConfirmButtonWithModal`, naming the amount and symbol, since the transfer cannot be undone. Only one send runs at a time; the row in flight shows a spinner and the others are disabled. On success the account's balances reload so the row reflects reality rather than an optimistic guess.

## Part 3 — PR3: the other chains
## Part 3 — the other chains, one PR each

BTC / EVM / SOL cannot be signed by OISY. Two viable paths, and the choice is a product decision rather than an engineering one — see Pending decisions.
_Resolved:_ the source wallet's helper canister is called to sign, and OISY broadcasts. Its `CallType` variant offers a `Send` mode that would broadcast for us; it is deliberately unused, so we see the transaction hash and the RPC error directly and a third party stays off the critical path for everything but the signature.

The interface is fetched from the canister via `__get_candid_interface_tmp_hack` and vendored like any other third-party canister, because it is published nowhere else. `eth_address` confirmed the canister accepts callers other than the source wallet, and returned exactly the address derived offline.

Note the asymmetry that makes this necessary at all: on the management canister, `ecdsa_public_key` and `schnorr_public_key` take a `canister_id` argument, but `sign_with_ecdsa` and `sign_with_schnorr` do not — the key is derived from the caller. So these addresses are readable by anyone and signable only by that canister. **If it ever stops answering, those funds are unreachable by anybody, the source wallet included.** No implementation here can change that.

### EVM

Gas is paid in the network's native coin out of the _imported_ account, which drives the whole shape:

- A **native** send can only move `balance - gas`; sending the full balance always fails.
- A **token** send needs native coin sitting in that same account — a common gap for someone who only ever received tokens. The row is blocked with the coin named, rather than greyed out.
- Both are enforced in the send path against live fee data, not trusted from the UI, because the fee moves between the moment a row renders and the moment the user confirms.

Fee data comes from `getEthFeeDataWithProvider`, which already applies the per-chain floors (BSC minimums) — worth reusing rather than reading `getFeeData` raw. The nonce comes from OISY's own provider rather than the canister's `transaction_count`, keeping a third party off the path.

The EVM destination is the user's **OISY EVM address**, not their principal — unlike the IC sweep.

**A row's identity is network plus symbol.** Symbol alone is not unique and neither is the address: the same token enabled on several EVM networks yields rows sharing both. The first cut keyed the in-flight send on symbol, which would have spun two rows at once.

### BTC and SOL

Not yet built. SOL should fit OISY's existing `TransactionPartialSigner` abstraction via `sign_sol`. BTC needs UTXOs, fee percentiles and a broadcast path — the last is an open question, since `bitcoin_send_transaction` is canister-only.

## PRODUCT.md updates (land with the behaviour change)

Expand All @@ -124,6 +146,6 @@ BTC / EVM / SOL cannot be signed by OISY. Two viable paths, and the choice is a

## Pending decisions (facts are clear — someone needs to decide)

- **PR3 approach.** Either (a) call Plug's helper canister as the imported identity — full in-OISY UX for all chains, but an undocumented dependency on a third party's canister that can change under us, and possibly their commission; or (b) show the user their OISY destination addresses and have them send from within Plug — zero dependency, extra step for the user. (a) should not ship as a silently reverse-engineered integration; it warrants talking to Plug first.
- ~~**PR3 approach.**~~ _Resolved:_ (a) call the helper canister to sign, broadcast from OISY. Verified that plain sends carry no commission (the commission constants in their bundle belong to the HyperLiquid/trade paths) and that the canister accepts outside callers. The residual risk is that this is an undocumented interface which their upgrade can break; it is accepted deliberately, on the assumption the canister stays alive.
- **Whether the page ships behind a feature flag.** The `$env/*.env.ts` flag pattern is available. Given it is a Settings-linked page handling seed phrases, a flag would allow shipping the read path without exposing it until reviewed.
- **Security review scope.** This is the first OISY code to hold raw private key material in memory. The review should cover the memory-hygiene guarantees above (no autofill, no URL, no logs, no persistence) as much as the derivation correctness.
6 changes: 5 additions & 1 deletion src/frontend/src/eth/services/fee.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { infuraProviders, type InfuraProvider } from '$eth/providers/infura.prov
import { InfuraGasRest } from '$eth/rest/infura.rest';
import type { EthAddress, OptionEthAddress } from '$eth/types/address';
import type { Erc20Token } from '$eth/types/erc20';
import type { Erc4626Token } from '$eth/types/erc4626';
import type { GetFeeData } from '$eth/types/infura';
import type { EthereumChainId, EthereumNetwork } from '$eth/types/network';
import { isDestinationContractAddress } from '$eth/utils/send.utils';
Expand Down Expand Up @@ -58,7 +59,10 @@ export const getErc20FeeData = async ({
amount,
...rest
}: GetFeeData & {
contract: Erc20Token;
// ERC-4626 vault shares transfer through the ERC-20 `transfer` on their own
// contract, so gas is estimated the same way; only `address` / `symbol` / `name`
// are read here, which both token shapes provide.
contract: Erc20Token | Erc4626Token;
amount: bigint;
sourceNetwork: EthereumNetwork;
targetNetwork: Network | undefined;
Expand Down
90 changes: 77 additions & 13 deletions src/frontend/src/lib/components/plug-import/PlugImport.svelte
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
<script lang="ts">
import { isNullish } from '@dfinity/utils';
import type { Principal } from '@icp-sdk/core/principal';
import { SvelteMap } from 'svelte/reactivity';
import { mapAddressStartsWith0x } from '$icp-eth/utils/eth.utils';
import PlugImportAccount from '$lib/components/plug-import/PlugImportAccount.svelte';
import PlugImportForm from '$lib/components/plug-import/PlugImportForm.svelte';
import { ZERO } from '$lib/constants/app.constants';
import { PLUG_IMPORT_ERROR, PLUG_IMPORT_NOTICES } from '$lib/constants/test-ids.constants';
import { ethAddress } from '$lib/derived/address.derived';
import { authIdentity } from '$lib/derived/auth.derived';
import { enabledFungibleTokens } from '$lib/derived/tokens.derived';
import { sweepPlugEvmBalance } from '$lib/services/plug-evm.services';
import { loadPlugBalances, sweepPlugBalance } from '$lib/services/plug.services';
import { i18n } from '$lib/stores/i18n.store';
import { toastsError, toastsShow } from '$lib/stores/toasts.store';
import type { NetworkId } from '$lib/types/network';
import type { PlugAccount, PlugBalance } from '$lib/types/plug';
import { replacePlaceholders } from '$lib/utils/i18n.utils';
import { isNetworkIdEthereum, isNetworkIdEvm } from '$lib/utils/network.utils';
import {
derivePlugAccounts,
derivePlugIdentity,
isPlugSweepableToken
isPlugEvmContractToken,
isPlugSweepableToken,
plugEvmNetwork,
plugRowKey
} from '$lib/utils/plug.utils';

// The phrase lives here and nowhere else: no store, no storage, no URL. Leaving
Expand All @@ -39,33 +49,87 @@
balances.set(account.index, loaded);
};

// Which chain's send path a row takes. The imported identity is derived per call
// and never stored, so it lives only for the duration of one transfer.
const sendFor = async ({
account,
row: { token, address },
amount,
destination
}: {
account: PlugAccount;
row: PlugBalance;
amount: bigint;
destination: Principal;
}): Promise<void> => {
const identity = derivePlugIdentity({ phrase, index: account.index });
const { network } = token;

if (isNetworkIdEthereum(network.id) || isNetworkIdEvm(network.id)) {
const evmNetwork = plugEvmNetwork(network.id);

if (isNullish(evmNetwork)) {
throw new Error(`No EVM network configured for ${network.name}`);
}

// The EVM destination is the user's own OISY EVM address, not their principal.
if (isNullish($ethAddress)) {
throw new Error('Your OISY Ethereum address is not loaded yet');
}

await sweepPlugEvmBalance({
identity,
token,
balance: amount,
nativeBalance: nativeBalanceFor({ account, networkId: network.id }),
destination: mapAddressStartsWith0x($ethAddress),
from: address,
network: evmNetwork
});

return;
}

if (!isPlugSweepableToken(token)) {
throw new Error(`No send path for ${token.symbol} on ${network.name}`);
}

await sweepPlugBalance({ identity, token, amount, destination });
};

const nativeBalanceFor = ({
account,
networkId
}: {
account: PlugAccount;
networkId: NetworkId;
}): bigint =>
(balances.get(account.index) ?? []).find(
({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token)
)?.balance ?? ZERO;

const send = async ({
account,
balance: { token },
balance: row,
amount
}: {
account: PlugAccount;
balance: PlugBalance;
amount: bigint;
}): Promise<void> => {
const { token } = row;
const destination = $authIdentity?.getPrincipal();

// Both are guaranteed by the UI — the row only offers an action for a sweepable
// IC token, and the page is behind auth — but the transfer must not be attempted
// on a half-known state.
if (isNullish(destination) || !isPlugSweepableToken(token)) {
// Guaranteed by the UI, which only offers an action on a movable row behind
// auth — but a transfer must not be attempted on a half-known state.
if (isNullish(destination)) {
return;
}

sending = token.symbol;
sending = plugRowKey(row);

try {
await sweepPlugBalance({
identity: derivePlugIdentity({ phrase, index: account.index }),
token,
amount,
destination
});
await sendFor({ account, row, amount, destination });

toastsShow({
text: replacePlaceholders($i18n.plug_import.text.send_success, { $symbol: token.symbol }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@
PLUG_IMPORT_SEND_DISABLED
} from '$lib/constants/test-ids.constants';
import { i18n } from '$lib/stores/i18n.store';
import type { NetworkId } from '$lib/types/network';
import type { PlugBalance, PlugAccount } from '$lib/types/plug';
import { formatToken, shortenWithMiddleEllipsis } from '$lib/utils/format.utils';
import { replacePlaceholders } from '$lib/utils/i18n.utils';
import { isPlugSweepableToken, plugSweepableAmount } from '$lib/utils/plug.utils';
import { isNetworkIdEthereum, isNetworkIdEvm } from '$lib/utils/network.utils';
import {
isPlugEvmContractToken,
isPlugEvmSendable,
isPlugSweepableToken,
plugRowKey,
plugSweepableAmount
} from '$lib/utils/plug.utils';

interface Props {
account: PlugAccount;
Expand All @@ -24,12 +32,37 @@

let { account, balances, sending, onsend }: Props = $props();

const isEvm = ({ token: { network } }: PlugBalance): boolean =>
isNetworkIdEthereum(network.id) || isNetworkIdEvm(network.id);

// How much a row can move, or undefined when it cannot move at all. EVM amounts
// are settled at send time against live fee data — gas is not knowable here — so
// the row reports the full balance and the service trims the reserve.
const sendableAmount = (row: PlugBalance): bigint | undefined => {
const { token, balance } = row;

if (isEvm(row)) {
return isPlugEvmSendable({ token, balance, balances: balances ?? [] }) ? balance : undefined;
}

return plugSweepableAmount({ token, balance });
};

// Why a row cannot be moved, or undefined when it can. A reason rather than a
// boolean, so a blocked row can say what is wrong instead of only greying out —
// "not an IC token" and "smaller than its own fee" need different wording.
const blockedReason = ({ token, balance }: PlugBalance): string | undefined => {
// boolean, so a blocked row can say what is wrong instead of only greying out:
// an unsupported chain, a missing gas balance and a balance below its own fee are
// three different problems with three different remedies.
const blockedReason = (row: PlugBalance): string | undefined => {
const { token, balance } = row;

if (isEvm(row)) {
return replacePlaceholders($i18n.plug_import.text.send_needs_gas, {
$symbol: nativeSymbol(token.network.id) ?? token.symbol
});
}

if (!isPlugSweepableToken(token)) {
return $i18n.plug_import.text.send_only_ic;
return $i18n.plug_import.text.send_unsupported_chain;
}

if (isNullish(plugSweepableAmount({ token, balance }))) {
Expand All @@ -39,6 +72,11 @@
return undefined;
};

const nativeSymbol = (networkId: NetworkId): string | undefined =>
(balances ?? []).find(
({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token)
)?.token.symbol;

let loaded = $derived(nonNullish(balances));

// A zero balance is noise on a migration screen — the user is looking for what is
Expand Down Expand Up @@ -71,9 +109,10 @@
<span class="text-tertiary">{$i18n.plug_import.text.empty_account}</span>
{:else}
<ul class="flex w-full flex-col gap-2">
{#each visible as row (`${row.token.symbol}-${row.address}`)}
{#each visible as row (plugRowKey(row))}
{@const { token, address, balance } = row}
{@const amount = plugSweepableAmount({ token, balance })}
{@const rowKey = plugRowKey(row)}
{@const amount = sendableAmount(row)}
{@const reason = blockedReason(row)}

<li class="flex w-full flex-row items-center justify-between gap-3">
Expand All @@ -99,7 +138,7 @@
{#if nonNullish(amount)}
<ConfirmButtonWithModal
onConfirm={() => onsend({ balance: row, amount })}
testId={`${PLUG_IMPORT_SEND_BUTTON}-${token.symbol}`}
testId={`${PLUG_IMPORT_SEND_BUTTON}-${rowKey}`}
>
{#snippet title()}
{$i18n.plug_import.text.send_confirm_title}
Expand All @@ -108,27 +147,35 @@
{#snippet button(onclick)}
<Button
disabled={nonNullish(sending)}
loading={sending === token.symbol}
loading={sending === rowKey}
{onclick}
paddingSmall
testId={`${PLUG_IMPORT_SEND_BUTTON}-${token.symbol}`}
testId={`${PLUG_IMPORT_SEND_BUTTON}-${rowKey}`}
type="button"
>
{$i18n.plug_import.text.send_to_wallet}
</Button>
{/snippet}

<p>
{replacePlaceholders($i18n.plug_import.text.send_confirm_description, {
$amount: formatToken({ value: amount, unitName: token.decimals }),
$symbol: token.symbol
})}
{#if isPlugEvmContractToken(token)}
{replacePlaceholders($i18n.plug_import.text.send_confirm_description_gas, {
$amount: formatToken({ value: amount, unitName: token.decimals }),
$symbol: token.symbol,
$native: nativeSymbol(token.network.id) ?? ''
})}
{:else}
{replacePlaceholders($i18n.plug_import.text.send_confirm_description, {
$amount: formatToken({ value: amount, unitName: token.decimals }),
$symbol: token.symbol
})}
{/if}
</p>
</ConfirmButtonWithModal>
{:else if nonNullish(reason)}
<span
class="max-w-48 text-right text-sm text-tertiary"
data-tid={`${PLUG_IMPORT_SEND_DISABLED}-${token.symbol}`}>{reason}</span
data-tid={`${PLUG_IMPORT_SEND_DISABLED}-${rowKey}`}>{reason}</span
>
{/if}
</span>
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/lib/i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2733,8 +2733,10 @@
"send_to_wallet": "",
"send_confirm_title": "",
"send_confirm_description": "",
"send_confirm_description_gas": "",
"send_success": "",
"send_only_ic": "",
"send_unsupported_chain": "",
"send_needs_gas": "",
"send_below_fee": "",
"unavailable_hint": "",
"not_shown_title": "",
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/lib/i18n/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2733,8 +2733,10 @@
"send_to_wallet": "",
"send_confirm_title": "",
"send_confirm_description": "",
"send_confirm_description_gas": "",
"send_success": "",
"send_only_ic": "",
"send_unsupported_chain": "",
"send_needs_gas": "",
"send_below_fee": "",
"unavailable_hint": "",
"not_shown_title": "",
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/lib/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2733,8 +2733,10 @@
"send_to_wallet": "",
"send_confirm_title": "",
"send_confirm_description": "",
"send_confirm_description_gas": "",
"send_success": "",
"send_only_ic": "",
"send_unsupported_chain": "",
"send_needs_gas": "",
"send_below_fee": "",
"unavailable_hint": "",
"not_shown_title": "",
Expand Down
Loading
Loading