add evm send with native and erc-20 token support
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
"build:native": "swiftc -O -o src/bun/native/biometric-helper src/bun/native/biometric-helper.swift"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alchemy/wallet-apis": "^5.0.0-beta.28",
|
||||
"@alchemy/common": "^5.0.0",
|
||||
"@alchemy/wallet-apis": "^5.0.0",
|
||||
"@koins/utils": "workspace:*",
|
||||
"drizzle-orm": "^1.0.0-beta.22",
|
||||
"electrobun": "^1.18.1",
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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">
|
||||
← 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>
|
||||
Reference in New Issue
Block a user