add evm send with native and erc-20 token support

This commit is contained in:
2026-05-29 00:33:56 +08:00
parent 9e579f957d
commit 44a91428fc
7 changed files with 285 additions and 9 deletions
+64 -1
View File
@@ -10,7 +10,11 @@ import { syncTransactionHistory } from '../../lib/sync'
import { eq, and } from 'drizzle-orm'
import { db } from '../../lib/db'
import { getClient } from '../../lib/viem'
import { formatGwei } from 'viem'
import { formatGwei, parseEther, parseUnits, encodeFunctionData, createWalletClient } from 'viem'
import { mnemonicToAccount } from 'viem/accounts'
import { getChain } from '../../lib/viem/getChain'
import { getChainID } from '../../lib/alchemy/network'
import { alchemyTransport } from '@alchemy/common'
import { sqlite } from '../../lib/sqlite'
import { txHistory, evmWallets } from '../../lib/db/schema'
import type {
@@ -566,5 +570,64 @@ export function createEvmHandlers(
db.delete(evmWallets).where(eq(evmWallets.id, id)).run()
console.log('[rpc] evmDeleteWallet complete')
},
evmSendTransfer: async ({
seed,
to,
amount,
chainid,
contractAddress,
tokenDecimals,
}: {
seed: string
to: string
amount: string
chainid: string
contractAddress?: string
tokenDecimals?: number
}) => {
const key = await Bun.secrets.get({
service: 'koins',
name: 'alchemy_key',
})
if (!key) throw new Error('API key not set')
const account = mnemonicToAccount(seed)
const id = getChainID(chainid)
if (!id) throw new Error('Unsupported chain')
const chain = getChain(id)
const walletClient = createWalletClient({
account,
chain,
transport: alchemyTransport({ apiKey: key }),
})
if (contractAddress && tokenDecimals !== undefined) {
const erc20Abi = [
{
name: 'transfer',
type: 'function',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
},
] as const
const data = encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [to as `0x${string}`, parseUnits(amount, tokenDecimals)],
})
const hash = await walletClient.sendTransaction({
to: contractAddress as `0x${string}`,
data,
value: 0n,
})
return hash
}
const hash = await walletClient.sendTransaction({
to: to as `0x${string}`,
value: parseEther(amount),
})
return hash
},
}
}
@@ -487,6 +487,11 @@
{/if}
<div class="flex gap-2 flex-wrap">
<Button
variant="outline"
onclick={() => navigate('/multicoin/send')}>
Send
</Button>
<Button
variant="outline"
onclick={() => navigate('/settings')}>
+2
View File
@@ -117,6 +117,7 @@ export type TransactionDetails = {
export type TokenBalanceResult = {
symbol: string
decimals: number
balance: string
contractAddress: string
logo?: string
@@ -163,6 +164,7 @@ export type EvmRpcRequests = {
evmListWallets: { params: {}; response: EvmWalletInfo[] }
evmGetSeed: { params: { vaultKey: string }; response: string }
evmDeleteWallet: { params: { id: string }; response: void }
evmSendTransfer: { params: { seed: string; to: string; amount: string; chainid: string; contractAddress?: string; tokenDecimals?: number }; response: string }
}
export type MoneroRpcRequests = {
@@ -39,6 +39,7 @@ export type NetworkId = (typeof networks)[number]['id']
export type TokenBalance = {
symbol: string
decimals: number
balance: string
contractAddress: string
logo?: string
@@ -406,6 +407,25 @@ export const EvmWallet = () => {
transactions = txs ?? []
}
const send = async (
to: string,
amount: string,
contractAddress?: string,
tokenDecimals?: number,
) => {
if (!electrobun.rpc || !seed) throw new Error('Wallet not unlocked')
const hash = await electrobun.rpc.request.evmSendTransfer({
seed,
to,
amount,
chainid: net().chainid,
contractAddress,
tokenDecimals,
})
await refresh()
return hash
}
const lock = () => {
clearWallet()
}
@@ -531,6 +551,7 @@ export const EvmWallet = () => {
unlockWallet,
deleteWallet,
clearSelection,
send,
syncTxHistory,
setAutoSync,
flushTxCache,
@@ -0,0 +1,183 @@
<script lang="ts">
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
const w = wallet
type TokenOption = {
symbol: string
balance: string
contractAddress: string
decimals: number
logo?: string
}
const tokens = $derived<TokenOption[]>([
{
symbol: w.symbol,
balance: w.balance,
contractAddress: '',
decimals: 18,
},
...w.tokenBalances.map((t) => ({
symbol: t.symbol,
balance: t.balance,
contractAddress: t.contractAddress,
decimals: t.decimals,
logo: t.logo,
})),
])
let selectedSymbol = $state(w.symbol)
let recipient = $state('')
let amount = $state('')
let sending = $state(false)
let sentResult = $state<{ txHash: string } | null>(null)
let sendError = $state('')
const selectedToken = $derived(
tokens.find((t) => t.symbol === selectedSymbol) ?? tokens[0],
)
const exceedsBalance = $derived(
amount !== '' &&
selectedToken.balance !== '0' &&
parseFloat(amount) > parseFloat(selectedToken.balance),
)
const canSend = $derived(
recipient.length === 42 &&
recipient.startsWith('0x') &&
amount !== '' &&
parseFloat(amount) > 0 &&
!exceedsBalance &&
!sending,
)
const handleSend = async () => {
sending = true
sendError = ''
sentResult = null
try {
const txHash = await w.send(
recipient,
amount,
selectedToken.contractAddress || undefined,
selectedToken.decimals,
)
sentResult = { txHash }
recipient = ''
amount = ''
} catch (e) {
sendError = e instanceof Error ? e.message : 'Send failed'
} finally {
sending = false
}
}
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() => navigate(w.network === 'eth' ? '/multicoin' : `/multicoin/${w.network}`)}
class="mb-4">
&larr; Back
</Button>
<Card>
<CardHeader>
<CardTitle>Send</CardTitle>
<CardDescription>
Balance: {selectedToken.balance} {selectedToken.symbol}
</CardDescription>
</CardHeader>
<CardContent>
<form
onsubmit={(e) => {
e.preventDefault()
handleSend()
}}
class="space-y-4">
<div class="space-y-1.5">
<label class="text-xs font-medium">Asset</label>
<Select type="single" bind:value={selectedSymbol}>
<SelectTrigger>
{selectedToken.symbol}
</SelectTrigger>
<SelectContent>
{#each tokens as t}
<SelectItem value={t.symbol}>
{t.symbol} ({t.balance})
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Recipient Address</label>
<Input
placeholder="0x..."
bind:value={recipient} />
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Amount ({selectedToken.symbol})</label>
<Input
type="number"
step="0.000000000000000001"
min="0"
placeholder="0.0"
bind:value={amount} />
{#if exceedsBalance}
<p class="text-xs text-red-500">
Amount exceeds balance
</p>
{/if}
</div>
<Button type="submit" disabled={!canSend} class="w-full">
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
{#if sendError}
<p class="mt-3 text-xs text-red-500">{sendError}</p>
{/if}
{#if sentResult}
<div
class="mt-4 space-y-2 rounded-md border border-green-500/30 bg-green-500/5 p-3">
<p class="text-xs font-medium text-green-600">
Transaction Sent
</p>
<p class="font-mono text-xs break-all">
<a
href="{w.explorerUrl}{sentResult.txHash}"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-green-500">
{sentResult.txHash}
</a>
</p>
</div>
{/if}
</CardContent>
</Card>
</div>