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 @@ -281,4 +281,5 @@ Only Plug's **Internet Computer identity** is derived from the seed phrase. Plug
- **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.
- **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.
- **Solana balances can be moved too.** Native SOL and SPL tokens are signed by the original wallet's own canister and broadcast by OISY, reusing OISY's own Solana send (which creates the destination token account when needed). The fee is paid in SOL from the imported account, so a native send moves the balance minus the fee and an SPL send needs SOL there — a row that lacks it says so.
- **Bitcoin is still send-from-source.** Its transfer is not built yet, so the page says to send it from the original wallet to the user's OISY address.
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,15 @@ The EVM destination is the user's **OISY EVM address**, not their principal —

**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
### 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.
`sign_sol` is raw Ed25519 over the message bytes, which is exactly what a `TransactionPartialSigner` produces — so the Plug signer drops into OISY's own `sendSol` in place of the default. `sendSol` gains one optional `signerOverride` parameter (backward-compatible for its three existing callers); everything else — building the transfer, creating the destination token account when the OISY side does not hold that token yet, broadcast, confirm — is reused.

The fee is paid in SOL from the imported account, so it mirrors EVM: a native send moves `balance - fee`, an SPL send needs SOL present, and the gating is shared with EVM through the `isPlugFeeChain` / `isPlugGasToken` predicates rather than duplicated. SPL sufficiency beyond the base fee (the ATA rent) is left to the network to reject, rather than estimated up front.

### BTC

Not yet built. It 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 Down
27 changes: 23 additions & 4 deletions src/frontend/src/lib/components/plug-import/PlugImport.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,22 @@
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 { ethAddress, solAddressMainnet } 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 { sweepPlugSolBalance } from '$lib/services/plug-sol.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 { isNetworkIdEthereum, isNetworkIdEvm, isNetworkIdSolana } from '$lib/utils/network.utils';
import {
derivePlugAccounts,
derivePlugIdentity,
isPlugEvmContractToken,
isPlugGasToken,
isPlugSweepableToken,
plugEvmNetwork,
plugRowKey
Expand Down Expand Up @@ -90,6 +91,24 @@
return;
}

if (isNetworkIdSolana(network.id)) {
// The Solana destination is the user's own OISY Solana address.
if (isNullish($solAddressMainnet)) {
throw new Error('Your OISY Solana address is not loaded yet');
}

await sweepPlugSolBalance({
identity,
token,
balance: amount,
nativeBalance: nativeBalanceFor({ account, networkId: network.id }),
destination: $solAddressMainnet,
source: address
});

return;
}

if (!isPlugSweepableToken(token)) {
throw new Error(`No send path for ${token.symbol} on ${network.name}`);
}
Expand All @@ -105,7 +124,7 @@
networkId: NetworkId;
}): bigint =>
(balances.get(account.index) ?? []).find(
({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token)
({ token }) => token.network.id === networkId && !isPlugGasToken(token)
)?.balance ?? ZERO;

const send = async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
import type { PlugBalance, PlugAccount } from '$lib/types/plug';
import { formatToken, shortenWithMiddleEllipsis } from '$lib/utils/format.utils';
import { replacePlaceholders } from '$lib/utils/i18n.utils';
import { isNetworkIdEthereum, isNetworkIdEvm } from '$lib/utils/network.utils';
import {
isPlugEvmContractToken,
isPlugEvmSendable,
isPlugFeeChain,
isPlugFeeChainSendable,
isPlugGasToken,
isPlugSweepableToken,
plugRowKey,
plugSweepableAmount
Expand All @@ -32,17 +32,18 @@

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

const isEvm = ({ token: { network } }: PlugBalance): boolean =>
isNetworkIdEthereum(network.id) || isNetworkIdEvm(network.id);
const isFeeChain = ({ token: { network } }: PlugBalance): boolean => isPlugFeeChain(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.
// How much a row can move, or undefined when it cannot move at all. On fee chains
// (EVM / Solana) the exact fee is settled at send time, 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;
if (isFeeChain(row)) {
return isPlugFeeChainSendable({ token, balance, balances: balances ?? [] })
? balance
: undefined;
}

return plugSweepableAmount({ token, balance });
Expand All @@ -55,10 +56,14 @@
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 (isFeeChain(row)) {
// A native coin pays its own fee, so if it is blocked here the balance is
// simply unavailable — only a token missing its gas coin has advice to give.
return isPlugGasToken(token)
? replacePlaceholders($i18n.plug_import.text.send_needs_gas, {
$symbol: nativeSymbol(token.network.id) ?? token.symbol
})
: undefined;
}

if (!isPlugSweepableToken(token)) {
Expand All @@ -73,9 +78,8 @@
};

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

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

Expand Down Expand Up @@ -158,7 +162,7 @@
{/snippet}

<p>
{#if isPlugEvmContractToken(token)}
{#if isPlugGasToken(token)}
{replacePlaceholders($i18n.plug_import.text.send_confirm_description_gas, {
$amount: formatToken({ value: amount, unitName: token.decimals }),
$symbol: token.symbol,
Expand Down
126 changes: 126 additions & 0 deletions src/frontend/src/lib/services/plug-sol.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { signPlugSolMessage } from '$lib/api/plug-helper.api';
import { ZERO } from '$lib/constants/app.constants';
import type { Token } from '$lib/types/token';
import { SOLANA_TRANSACTION_FEE_IN_LAMPORTS } from '$sol/constants/sol.constants';
import { sendSol } from '$sol/services/sol-send.services';
import type { SolAddress } from '$sol/types/address';
import { isTokenSpl } from '$sol/utils/spl.utils';
import type { Identity } from '@icp-sdk/core/agent';
import {
assertIsTransactionPartialSigner,
assertIsTransactionSigner,
address as solAddress,
type Signature,
type SignatureDictionary,
type Transaction,
type TransactionPartialSigner,
type TransactionWithinSizeLimit,
type TransactionWithLifetime
} from '@solana/kit';

/**
* A Solana signer backed by the imported wallet's own canister.
*
* Solana signing is raw Ed25519 over the message bytes — no hashing, no recovery
* id — so the helper canister's `sign_sol` takes the transaction message and
* returns the signature unchanged, exactly matching what a `TransactionPartialSigner`
* must produce. Only that canister can sign for this address, so the imported
* identity, not the signed-in one, must make the call.
*/
const createPlugSolSigner = ({
identity,
address
}: {
identity: Identity;
address: SolAddress;
}): TransactionPartialSigner => {
const signer: TransactionPartialSigner = {
address: solAddress(address),
signTransactions: async (
transactions: (Transaction & TransactionWithinSizeLimit & TransactionWithLifetime)[]
): Promise<SignatureDictionary[]> =>
await Promise.all(
transactions.map(
async (transaction) =>
({
[address]: await signPlugSolMessage({
identity,
message: Uint8Array.from(transaction.messageBytes)
})
}) as SignatureDictionary
)
)
};

assertIsTransactionSigner(signer);
assertIsTransactionPartialSigner(signer);

return signer;
};

/**
* Sends an imported wallet's Solana balance to the signed-in user's own address.
*
* The whole build/sign/broadcast/confirm flow is OISY's own `sendSol`; only the
* signer is swapped for one backed by the imported wallet's canister.
*
* The network fee is paid in SOL from the *imported* account. A native send can
* therefore only move `balance - fee`, and an SPL send needs SOL there for the
* fee (and for creating the destination token account when the OISY side does not
* hold that token yet). The SPL sufficiency beyond the base fee is left to the
* network, which rejects an underfunded transaction rather than half-applying it.
*/
export const sweepPlugSolBalance = async ({
identity,
token,
balance,
nativeBalance,
destination,
source
}: {
identity: Identity;
token: Token;
balance: bigint;
nativeBalance: bigint;
destination: SolAddress;
source: SolAddress;
}): Promise<Signature> => {
if (isTokenSpl(token)) {
if (nativeBalance <= SOLANA_TRANSACTION_FEE_IN_LAMPORTS) {
throw new Error('Not enough SOL to cover the fee for this token transfer');
}

return await runSweep({ identity, token, amount: balance, destination, source });
}

const amount = balance - SOLANA_TRANSACTION_FEE_IN_LAMPORTS;

if (amount <= ZERO) {
throw new Error('Balance does not cover the network fee for this transfer');
}

return await runSweep({ identity, token, amount, destination, source });
};

const runSweep = async ({
identity,
token,
amount,
destination,
source
}: {
identity: Identity;
token: Token;
amount: bigint;
destination: SolAddress;
source: SolAddress;
}): Promise<Signature> =>
await sendSol({
identity,
token,
amount,
prioritizationFee: ZERO,
destination,
source,
signerOverride: createPlugSolSigner({ identity, address: source })
});
38 changes: 28 additions & 10 deletions src/frontend/src/lib/utils/plug.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { SIGNER_MASTER_PUB_KEYS } from '$lib/constants/signer.constants';
import type { NetworkId } from '$lib/types/network';
import type { PlugAccount, PlugBalance } from '$lib/types/plug';
import type { Token } from '$lib/types/token';
import { isNetworkIdEthereum, isNetworkIdEvm, isNetworkIdSolana } from '$lib/utils/network.utils';
import type { SolAddress } from '$sol/types/address';
import { isTokenSpl } from '$sol/utils/spl.utils';
import { secp256k1 } from '@dfinity/ic-pub-key/ecdsa';
import { bip340secp256k1, ed25519 } from '@dfinity/ic-pub-key/schnorr';
import { isNullish, nonNullish } from '@dfinity/utils';
Expand Down Expand Up @@ -198,6 +200,23 @@ export const plugSweepableAmount = ({
export const isPlugEvmContractToken = (token: Token): token is Erc20Token | Erc4626Token =>
isTokenErc20(token) || isTokenErc4626(token);

/**
* A token whose network fee is paid in a *separate* native coin — ERC-20/ERC-4626
* (gas in ETH) and SPL (fee in SOL). Moving one is impossible without that coin in
* the same account, which is what distinguishes it from a native coin that pays
* its own fee. Used to gate the send action and to find the native row.
*/
export const isPlugGasToken = (token: Token): boolean =>
isPlugEvmContractToken(token) || isTokenSpl(token);

/**
* A chain where the fee is paid from a native balance in the account, so a token
* needs that coin present and a native send must reserve it. Both EVM and Solana
* work this way; IC ledgers charge the fee in the token itself.
*/
export const isPlugFeeChain = (networkId: NetworkId): boolean =>
isNetworkIdEthereum(networkId) || isNetworkIdEvm(networkId) || isNetworkIdSolana(networkId);

/**
* Identifies a balance row.
*
Expand All @@ -215,19 +234,18 @@ const plugNativeBalance = ({
networkId: NetworkId;
balances: PlugBalance[];
}): bigint | undefined =>
balances.find(({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token))
?.balance;
balances.find(({ token }) => token.network.id === networkId && !isPlugGasToken(token))?.balance;

/**
* Whether an EVM row can be moved.
* Whether a fee-chain (EVM / Solana) row can be moved.
*
* Gas is paid in the network's native coin out of the imported account, so a
* token transfer is impossible without native coin sitting there — a common state
* for someone who only ever received tokens. The exact gas figure needs live fee
* data, so this is the cheap gate that keeps an impossible action off the screen;
* the send path re-checks against the real fee.
* The fee is paid in the network's native coin out of the imported account, so a
* token transfer is impossible without that coin sitting there — a common state
* for someone who only ever received tokens. The exact fee needs live data, so
* this is the cheap gate that keeps an impossible action off the screen; the send
* path re-checks against the real fee.
*/
export const isPlugEvmSendable = ({
export const isPlugFeeChainSendable = ({
token,
balance,
balances
Expand All @@ -240,7 +258,7 @@ export const isPlugEvmSendable = ({
return false;
}

if (!isPlugEvmContractToken(token)) {
if (!isPlugGasToken(token)) {
return true;
}

Expand Down
Loading
Loading