Skip to content
Merged
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
40 changes: 40 additions & 0 deletions miniapps/biobridge/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,44 @@ describe('Forge App', () => {
expect.objectContaining({ method: 'eth_requestAccounts' })
)
})

it('should allow reconnecting wallets from confirm step', async () => {
mockBio.request.mockImplementation(({ method, params }: { method: string; params?: Array<{ chain?: string }> }) => {
if (method === 'bio_closeSplashScreen') return Promise.resolve(null)
if (method === 'bio_selectAccount') {
const chain = params?.[0]?.chain
if (chain === 'ethereum') {
return Promise.resolve({ address: '0xexternal-bio', chain: 'ethereum' })
}
return Promise.resolve({ address: 'bfmeta123', chain: 'bfmeta' })
}
return Promise.resolve(null)
})

render(<App />)

await waitFor(() => {
expect(screen.getByTestId('connect-button')).toBeInTheDocument()
})

fireEvent.click(screen.getByTestId('connect-button'))

await waitFor(() => {
expect(screen.getByTestId('amount-input')).toBeInTheDocument()
})

fireEvent.change(screen.getByTestId('amount-input'), { target: { value: '1' } })
fireEvent.click(screen.getByTestId('preview-button'))

await waitFor(() => {
expect(screen.getByText('0xexternal-bio')).toBeInTheDocument()
expect(screen.getByText('bfmeta123')).toBeInTheDocument()
})

fireEvent.click(screen.getByTestId('reconnect-button'))

await waitFor(() => {
expect(screen.getByTestId('connect-button')).toBeInTheDocument()
})
})
})
90 changes: 74 additions & 16 deletions miniapps/biobridge/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import type { BridgeMode } from '@/api/types';

type RechargeStep = 'connect' | 'swap' | 'confirm' | 'processing' | 'success';

function normalizeIdForCompare(value: string | undefined): string {
return value?.trim().toLowerCase() ?? '';
}

const TOKEN_COLORS: Record<string, string> = {
ETH: 'bg-indigo-600',
BSC: 'bg-yellow-600',
Expand Down Expand Up @@ -229,6 +233,17 @@ export default function App() {

const handleConfirm = useCallback(async () => {
if (!externalAccount || !internalAccount || !selectedOption) return;
const expectedExternalChain = normalizeChainId(selectedOption.externalChain);
if (normalizeIdForCompare(externalAccount.chain) !== normalizeIdForCompare(expectedExternalChain)) {
setError(t('error.accountChainMismatch'));
setRechargeStep('connect');
return;
}
if (normalizeIdForCompare(internalAccount.chain) !== normalizeIdForCompare(selectedOption.internalChain)) {
setError(t('error.accountChainMismatch'));
setRechargeStep('connect');
return;
}
const tokenAddress = selectedOption.externalInfo.contract?.trim();
let effectiveExternalDecimals = resolvedExternalDecimals;
if (tokenAddress && effectiveExternalDecimals === undefined) {
Expand Down Expand Up @@ -283,6 +298,15 @@ export default function App() {
forgeHook.reset();
}, [forgeHook]);

const handleReconnectAccounts = useCallback(() => {
setExternalAccount(null);
setInternalAccount(null);
setAmount('');
setError(null);
forgeHook.reset();
setRechargeStep('connect');
}, [forgeHook]);

// Group options by external chain for picker
const groupedOptions = useMemo(() => {
const groups: Record<string, ForgeOption[]> = {};
Expand Down Expand Up @@ -577,14 +601,24 @@ export default function App() {
)}

<div className="mt-auto pt-4">
<Button
data-testid="preview-button"
className="h-12 w-full"
onClick={handlePreview}
disabled={!amount || parseFloat(amount) <= 0}
>
{t('forge.preview')}
</Button>
<div className="space-y-2">
<Button
data-testid="preview-button"
className="h-12 w-full"
onClick={handlePreview}
disabled={!amount || parseFloat(amount) <= 0}
>
{t('forge.preview')}
</Button>
<Button
variant="ghost"
className="h-10 w-full"
data-testid="reconnect-button"
onClick={handleReconnectAccounts}
>
{t('forge.changeWallets')}
</Button>
</div>
</div>
</motion.div>
)}
Expand Down Expand Up @@ -636,6 +670,20 @@ export default function App() {

<Card>
<CardContent className="space-y-3 py-4 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('forge.sender')}</span>
<span className="max-w-36 truncate font-mono text-xs">
{externalAccount?.address}
</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">{t('forge.receiver')}</span>
<span className="max-w-36 truncate font-mono text-xs">
{internalAccount?.address}
</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">{t('forge.ratio')}</span>
<span>{t('forge.ratioValue')}</span>
Expand Down Expand Up @@ -677,14 +725,24 @@ export default function App() {
</Card>

<div className="mt-auto pt-4">
<Button
data-testid="confirm-button"
className="h-12 w-full"
onClick={handleConfirm}
disabled={loading}
>
{t('forge.confirm')}
</Button>
<div className="space-y-2">
<Button
data-testid="confirm-button"
className="h-12 w-full"
onClick={handleConfirm}
disabled={loading}
>
{t('forge.confirm')}
</Button>
<Button
variant="ghost"
className="h-10 w-full"
data-testid="reconnect-button"
onClick={handleReconnectAccounts}
>
{t('forge.changeWallets')}
</Button>
</div>
</div>
</motion.div>
)}
Expand Down
6 changes: 5 additions & 1 deletion miniapps/biobridge/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"decimalsLoading": "Loading decimals...",
"decimalsFromTokenInfo": "token info",
"decimalsFallback": "default",
"contractHint": "Contract: {{address}}"
"contractHint": "Contract: {{address}}",
"changeWallets": "Change wallets",
"sender": "Sender",
"receiver": "Receiver"
},
"redemption": {
"title": "Redemption",
Expand Down Expand Up @@ -80,6 +83,7 @@
"invalidAmount": "Please enter a valid amount",
"decimalsLoading": "Token decimals are loading",
"missingDecimals": "Missing token decimals configuration",
"accountChainMismatch": "Wallet account does not match selected chain, reconnect wallets",
"forgeFailed": "Operation failed"
},
"picker": {
Expand Down
6 changes: 5 additions & 1 deletion miniapps/biobridge/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"decimalsLoading": "精度读取中...",
"decimalsFromTokenInfo": "TokenInfo",
"decimalsFallback": "默认",
"contractHint": "合约:{{address}}"
"contractHint": "合约:{{address}}",
"changeWallets": "重新连接账户",
"sender": "发送地址",
"receiver": "接收地址"
},
"redemption": {
"title": "赎回",
Expand Down Expand Up @@ -80,6 +83,7 @@
"invalidAmount": "请输入有效金额",
"decimalsLoading": "精度信息加载中,请稍候",
"missingDecimals": "缺少资产精度配置",
"accountChainMismatch": "账户与当前链不匹配,请重新连接账户",
"forgeFailed": "操作失败"
},
"picker": {
Expand Down
6 changes: 5 additions & 1 deletion miniapps/biobridge/src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"decimalsLoading": "精度讀取中...",
"decimalsFromTokenInfo": "TokenInfo",
"decimalsFallback": "預設",
"contractHint": "合約:{{address}}"
"contractHint": "合約:{{address}}",
"changeWallets": "重新連接帳戶",
"sender": "發送地址",
"receiver": "接收地址"
},
"redemption": {
"title": "贖回",
Expand Down Expand Up @@ -80,6 +83,7 @@
"invalidAmount": "請輸入有效金額",
"decimalsLoading": "精度資訊載入中,請稍候",
"missingDecimals": "缺少資產精度配置",
"accountChainMismatch": "帳戶與當前鏈不匹配,請重新連接帳戶",
"forgeFailed": "操作失敗"
},
"picker": {
Expand Down
6 changes: 5 additions & 1 deletion miniapps/biobridge/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"decimalsLoading": "精度读取中...",
"decimalsFromTokenInfo": "TokenInfo",
"decimalsFallback": "默认",
"contractHint": "合约:{{address}}"
"contractHint": "合约:{{address}}",
"changeWallets": "重新连接账户",
"sender": "发送地址",
"receiver": "接收地址"
},
"redemption": {
"title": "赎回",
Expand Down Expand Up @@ -80,6 +83,7 @@
"invalidAmount": "请输入有效金额",
"decimalsLoading": "精度信息加载中,请稍候",
"missingDecimals": "缺少资产精度配置",
"accountChainMismatch": "账户与当前链不匹配,请重新连接账户",
"forgeFailed": "操作失败"
},
"picker": {
Expand Down
43 changes: 43 additions & 0 deletions src/services/chain-adapter/evm/transaction-mixin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { hexToBytes } from '@noble/hashes/utils.js'
import { privateKeyToAccount } from 'viem/accounts'
import { recoverTransactionAddress } from 'viem'

import { EvmTransactionMixin } from './transaction-mixin'
import type { UnsignedTransaction } from '../types'

class EvmSignTestBase {
constructor(public readonly chainId: string) {}
}

class EvmSignTestService extends EvmTransactionMixin(EvmSignTestBase) {}

describe('EvmTransactionMixin.signTransaction', () => {
it('signs token tx with value=0x0 and keeps sender address consistent', async () => {
const service = new EvmSignTestService('binance')
const privateKeyHex = '0x59c6995e998f97a5a0044976f5d8f17f4b10df9589ef5f8a7f6f3f6db74ad5a4'
const expectedAddress = privateKeyToAccount(privateKeyHex).address.toLowerCase()

const unsignedTx: UnsignedTransaction = {
chainId: 'binance',
intentType: 'transfer',
data: {
nonce: 137,
gasPrice: '0x2faf080',
gasLimit: '0x249f0',
to: '0x55d398326f99059ff775485246999027b3197955',
value: '0x0',
data: '0xa9059cbb000000000000000000000000063096cbc147d5170e1b10fa4895bfa882c1d45e0000000000000000000000000000000000000000000000008ac7230489e80000',
chainId: 56,
},
}

const signed = await service.signTransaction(unsignedTx, {
privateKey: hexToBytes(privateKeyHex.slice(2)),
})

const serialized = signed.data as `0x${string}`
const recoveredAddress = (await recoverTransactionAddress({ serializedTransaction: serialized })).toLowerCase()
expect(recoveredAddress).toBe(expectedAddress)
})
})
27 changes: 21 additions & 6 deletions src/services/chain-adapter/evm/transaction-mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,16 @@ export function EvmTransactionMixin<TBase extends Constructor<{ chainId: string
chainId: number | string
}
const chainId = await this.#resolveChainId(txData.chainId)
const gasPrice = this.#normalizeQuantityHex(txData.gasPrice)
const gasLimit = this.#normalizeQuantityHex(txData.gasLimit)
const value = this.#normalizeQuantityHex(txData.value)

const rawTx = this.#rlpEncode([
this.#toRlpHex(txData.nonce),
txData.gasPrice,
txData.gasLimit,
gasPrice,
gasLimit,
txData.to.toLowerCase(),
txData.value,
value,
txData.data,
this.#toRlpHex(chainId),
'0x',
Expand All @@ -228,10 +231,10 @@ export function EvmTransactionMixin<TBase extends Constructor<{ chainId: string

const signedRaw = this.#rlpEncode([
this.#toRlpHex(txData.nonce),
txData.gasPrice,
txData.gasLimit,
gasPrice,
gasLimit,
txData.to.toLowerCase(),
txData.value,
value,
txData.data,
this.#toRlpHex(v),
'0x' + rHex,
Expand Down Expand Up @@ -259,6 +262,18 @@ export function EvmTransactionMixin<TBase extends Constructor<{ chainId: string
return '0x' + n.toString(16)
}

#normalizeQuantityHex(value: string): string {
const trimmed = value.trim()
if (!trimmed.startsWith('0x') && !trimmed.startsWith('0X')) {
return trimmed
}
const hex = trimmed.slice(2).replace(/^0+/, '')
if (hex.length === 0) {
return '0x'
}
return `0x${hex}`
}

#rlpEncode(items: string[]): string {
const encoded = items.map((item) => {
if (item === '0x' || item === '') {
Expand Down