Skip to content

Commit 922b0ce

Browse files
authored
fix(biobridge): correct bsc signer recovery and add account safeguards (#483)
1 parent 0156440 commit 922b0ce

8 files changed

Lines changed: 198 additions & 26 deletions

File tree

miniapps/biobridge/src/App.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,4 +215,44 @@ describe('Forge App', () => {
215215
expect.objectContaining({ method: 'eth_requestAccounts' })
216216
)
217217
})
218+
219+
it('should allow reconnecting wallets from confirm step', async () => {
220+
mockBio.request.mockImplementation(({ method, params }: { method: string; params?: Array<{ chain?: string }> }) => {
221+
if (method === 'bio_closeSplashScreen') return Promise.resolve(null)
222+
if (method === 'bio_selectAccount') {
223+
const chain = params?.[0]?.chain
224+
if (chain === 'ethereum') {
225+
return Promise.resolve({ address: '0xexternal-bio', chain: 'ethereum' })
226+
}
227+
return Promise.resolve({ address: 'bfmeta123', chain: 'bfmeta' })
228+
}
229+
return Promise.resolve(null)
230+
})
231+
232+
render(<App />)
233+
234+
await waitFor(() => {
235+
expect(screen.getByTestId('connect-button')).toBeInTheDocument()
236+
})
237+
238+
fireEvent.click(screen.getByTestId('connect-button'))
239+
240+
await waitFor(() => {
241+
expect(screen.getByTestId('amount-input')).toBeInTheDocument()
242+
})
243+
244+
fireEvent.change(screen.getByTestId('amount-input'), { target: { value: '1' } })
245+
fireEvent.click(screen.getByTestId('preview-button'))
246+
247+
await waitFor(() => {
248+
expect(screen.getByText('0xexternal-bio')).toBeInTheDocument()
249+
expect(screen.getByText('bfmeta123')).toBeInTheDocument()
250+
})
251+
252+
fireEvent.click(screen.getByTestId('reconnect-button'))
253+
254+
await waitFor(() => {
255+
expect(screen.getByTestId('connect-button')).toBeInTheDocument()
256+
})
257+
})
218258
})

miniapps/biobridge/src/App.tsx

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import type { BridgeMode } from '@/api/types';
3939

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

42+
function normalizeIdForCompare(value: string | undefined): string {
43+
return value?.trim().toLowerCase() ?? '';
44+
}
45+
4246
const TOKEN_COLORS: Record<string, string> = {
4347
ETH: 'bg-indigo-600',
4448
BSC: 'bg-yellow-600',
@@ -229,6 +233,17 @@ export default function App() {
229233

230234
const handleConfirm = useCallback(async () => {
231235
if (!externalAccount || !internalAccount || !selectedOption) return;
236+
const expectedExternalChain = normalizeChainId(selectedOption.externalChain);
237+
if (normalizeIdForCompare(externalAccount.chain) !== normalizeIdForCompare(expectedExternalChain)) {
238+
setError(t('error.accountChainMismatch'));
239+
setRechargeStep('connect');
240+
return;
241+
}
242+
if (normalizeIdForCompare(internalAccount.chain) !== normalizeIdForCompare(selectedOption.internalChain)) {
243+
setError(t('error.accountChainMismatch'));
244+
setRechargeStep('connect');
245+
return;
246+
}
232247
const tokenAddress = selectedOption.externalInfo.contract?.trim();
233248
let effectiveExternalDecimals = resolvedExternalDecimals;
234249
if (tokenAddress && effectiveExternalDecimals === undefined) {
@@ -283,6 +298,15 @@ export default function App() {
283298
forgeHook.reset();
284299
}, [forgeHook]);
285300

301+
const handleReconnectAccounts = useCallback(() => {
302+
setExternalAccount(null);
303+
setInternalAccount(null);
304+
setAmount('');
305+
setError(null);
306+
forgeHook.reset();
307+
setRechargeStep('connect');
308+
}, [forgeHook]);
309+
286310
// Group options by external chain for picker
287311
const groupedOptions = useMemo(() => {
288312
const groups: Record<string, ForgeOption[]> = {};
@@ -577,14 +601,24 @@ export default function App() {
577601
)}
578602

579603
<div className="mt-auto pt-4">
580-
<Button
581-
data-testid="preview-button"
582-
className="h-12 w-full"
583-
onClick={handlePreview}
584-
disabled={!amount || parseFloat(amount) <= 0}
585-
>
586-
{t('forge.preview')}
587-
</Button>
604+
<div className="space-y-2">
605+
<Button
606+
data-testid="preview-button"
607+
className="h-12 w-full"
608+
onClick={handlePreview}
609+
disabled={!amount || parseFloat(amount) <= 0}
610+
>
611+
{t('forge.preview')}
612+
</Button>
613+
<Button
614+
variant="ghost"
615+
className="h-10 w-full"
616+
data-testid="reconnect-button"
617+
onClick={handleReconnectAccounts}
618+
>
619+
{t('forge.changeWallets')}
620+
</Button>
621+
</div>
588622
</div>
589623
</motion.div>
590624
)}
@@ -636,6 +670,20 @@ export default function App() {
636670

637671
<Card>
638672
<CardContent className="space-y-3 py-4 text-sm">
673+
<div className="flex justify-between">
674+
<span className="text-muted-foreground">{t('forge.sender')}</span>
675+
<span className="max-w-36 truncate font-mono text-xs">
676+
{externalAccount?.address}
677+
</span>
678+
</div>
679+
<Separator />
680+
<div className="flex justify-between">
681+
<span className="text-muted-foreground">{t('forge.receiver')}</span>
682+
<span className="max-w-36 truncate font-mono text-xs">
683+
{internalAccount?.address}
684+
</span>
685+
</div>
686+
<Separator />
639687
<div className="flex justify-between">
640688
<span className="text-muted-foreground">{t('forge.ratio')}</span>
641689
<span>{t('forge.ratioValue')}</span>
@@ -677,14 +725,24 @@ export default function App() {
677725
</Card>
678726

679727
<div className="mt-auto pt-4">
680-
<Button
681-
data-testid="confirm-button"
682-
className="h-12 w-full"
683-
onClick={handleConfirm}
684-
disabled={loading}
685-
>
686-
{t('forge.confirm')}
687-
</Button>
728+
<div className="space-y-2">
729+
<Button
730+
data-testid="confirm-button"
731+
className="h-12 w-full"
732+
onClick={handleConfirm}
733+
disabled={loading}
734+
>
735+
{t('forge.confirm')}
736+
</Button>
737+
<Button
738+
variant="ghost"
739+
className="h-10 w-full"
740+
data-testid="reconnect-button"
741+
onClick={handleReconnectAccounts}
742+
>
743+
{t('forge.changeWallets')}
744+
</Button>
745+
</div>
688746
</div>
689747
</motion.div>
690748
)}

miniapps/biobridge/src/i18n/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
"decimalsLoading": "Loading decimals...",
2929
"decimalsFromTokenInfo": "token info",
3030
"decimalsFallback": "default",
31-
"contractHint": "Contract: {{address}}"
31+
"contractHint": "Contract: {{address}}",
32+
"changeWallets": "Change wallets",
33+
"sender": "Sender",
34+
"receiver": "Receiver"
3235
},
3336
"redemption": {
3437
"title": "Redemption",
@@ -80,6 +83,7 @@
8083
"invalidAmount": "Please enter a valid amount",
8184
"decimalsLoading": "Token decimals are loading",
8285
"missingDecimals": "Missing token decimals configuration",
86+
"accountChainMismatch": "Wallet account does not match selected chain, reconnect wallets",
8387
"forgeFailed": "Operation failed"
8488
},
8589
"picker": {

miniapps/biobridge/src/i18n/locales/zh-CN.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
"decimalsLoading": "精度读取中...",
2929
"decimalsFromTokenInfo": "TokenInfo",
3030
"decimalsFallback": "默认",
31-
"contractHint": "合约:{{address}}"
31+
"contractHint": "合约:{{address}}",
32+
"changeWallets": "重新连接账户",
33+
"sender": "发送地址",
34+
"receiver": "接收地址"
3235
},
3336
"redemption": {
3437
"title": "赎回",
@@ -80,6 +83,7 @@
8083
"invalidAmount": "请输入有效金额",
8184
"decimalsLoading": "精度信息加载中,请稍候",
8285
"missingDecimals": "缺少资产精度配置",
86+
"accountChainMismatch": "账户与当前链不匹配,请重新连接账户",
8387
"forgeFailed": "操作失败"
8488
},
8589
"picker": {

miniapps/biobridge/src/i18n/locales/zh-TW.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
"decimalsLoading": "精度讀取中...",
2929
"decimalsFromTokenInfo": "TokenInfo",
3030
"decimalsFallback": "預設",
31-
"contractHint": "合約:{{address}}"
31+
"contractHint": "合約:{{address}}",
32+
"changeWallets": "重新連接帳戶",
33+
"sender": "發送地址",
34+
"receiver": "接收地址"
3235
},
3336
"redemption": {
3437
"title": "贖回",
@@ -80,6 +83,7 @@
8083
"invalidAmount": "請輸入有效金額",
8184
"decimalsLoading": "精度資訊載入中,請稍候",
8285
"missingDecimals": "缺少資產精度配置",
86+
"accountChainMismatch": "帳戶與當前鏈不匹配,請重新連接帳戶",
8387
"forgeFailed": "操作失敗"
8488
},
8589
"picker": {

miniapps/biobridge/src/i18n/locales/zh.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
"decimalsLoading": "精度读取中...",
2929
"decimalsFromTokenInfo": "TokenInfo",
3030
"decimalsFallback": "默认",
31-
"contractHint": "合约:{{address}}"
31+
"contractHint": "合约:{{address}}",
32+
"changeWallets": "重新连接账户",
33+
"sender": "发送地址",
34+
"receiver": "接收地址"
3235
},
3336
"redemption": {
3437
"title": "赎回",
@@ -80,6 +83,7 @@
8083
"invalidAmount": "请输入有效金额",
8184
"decimalsLoading": "精度信息加载中,请稍候",
8285
"missingDecimals": "缺少资产精度配置",
86+
"accountChainMismatch": "账户与当前链不匹配,请重新连接账户",
8387
"forgeFailed": "操作失败"
8488
},
8589
"picker": {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { hexToBytes } from '@noble/hashes/utils.js'
3+
import { privateKeyToAccount } from 'viem/accounts'
4+
import { recoverTransactionAddress } from 'viem'
5+
6+
import { EvmTransactionMixin } from './transaction-mixin'
7+
import type { UnsignedTransaction } from '../types'
8+
9+
class EvmSignTestBase {
10+
constructor(public readonly chainId: string) {}
11+
}
12+
13+
class EvmSignTestService extends EvmTransactionMixin(EvmSignTestBase) {}
14+
15+
describe('EvmTransactionMixin.signTransaction', () => {
16+
it('signs token tx with value=0x0 and keeps sender address consistent', async () => {
17+
const service = new EvmSignTestService('binance')
18+
const privateKeyHex = '0x59c6995e998f97a5a0044976f5d8f17f4b10df9589ef5f8a7f6f3f6db74ad5a4'
19+
const expectedAddress = privateKeyToAccount(privateKeyHex).address.toLowerCase()
20+
21+
const unsignedTx: UnsignedTransaction = {
22+
chainId: 'binance',
23+
intentType: 'transfer',
24+
data: {
25+
nonce: 137,
26+
gasPrice: '0x2faf080',
27+
gasLimit: '0x249f0',
28+
to: '0x55d398326f99059ff775485246999027b3197955',
29+
value: '0x0',
30+
data: '0xa9059cbb000000000000000000000000063096cbc147d5170e1b10fa4895bfa882c1d45e0000000000000000000000000000000000000000000000008ac7230489e80000',
31+
chainId: 56,
32+
},
33+
}
34+
35+
const signed = await service.signTransaction(unsignedTx, {
36+
privateKey: hexToBytes(privateKeyHex.slice(2)),
37+
})
38+
39+
const serialized = signed.data as `0x${string}`
40+
const recoveredAddress = (await recoverTransactionAddress({ serializedTransaction: serialized })).toLowerCase()
41+
expect(recoveredAddress).toBe(expectedAddress)
42+
})
43+
})

src/services/chain-adapter/evm/transaction-mixin.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,13 +202,16 @@ export function EvmTransactionMixin<TBase extends Constructor<{ chainId: string
202202
chainId: number | string
203203
}
204204
const chainId = await this.#resolveChainId(txData.chainId)
205+
const gasPrice = this.#normalizeQuantityHex(txData.gasPrice)
206+
const gasLimit = this.#normalizeQuantityHex(txData.gasLimit)
207+
const value = this.#normalizeQuantityHex(txData.value)
205208

206209
const rawTx = this.#rlpEncode([
207210
this.#toRlpHex(txData.nonce),
208-
txData.gasPrice,
209-
txData.gasLimit,
211+
gasPrice,
212+
gasLimit,
210213
txData.to.toLowerCase(),
211-
txData.value,
214+
value,
212215
txData.data,
213216
this.#toRlpHex(chainId),
214217
'0x',
@@ -228,10 +231,10 @@ export function EvmTransactionMixin<TBase extends Constructor<{ chainId: string
228231

229232
const signedRaw = this.#rlpEncode([
230233
this.#toRlpHex(txData.nonce),
231-
txData.gasPrice,
232-
txData.gasLimit,
234+
gasPrice,
235+
gasLimit,
233236
txData.to.toLowerCase(),
234-
txData.value,
237+
value,
235238
txData.data,
236239
this.#toRlpHex(v),
237240
'0x' + rHex,
@@ -259,6 +262,18 @@ export function EvmTransactionMixin<TBase extends Constructor<{ chainId: string
259262
return '0x' + n.toString(16)
260263
}
261264

265+
#normalizeQuantityHex(value: string): string {
266+
const trimmed = value.trim()
267+
if (!trimmed.startsWith('0x') && !trimmed.startsWith('0X')) {
268+
return trimmed
269+
}
270+
const hex = trimmed.slice(2).replace(/^0+/, '')
271+
if (hex.length === 0) {
272+
return '0x'
273+
}
274+
return `0x${hex}`
275+
}
276+
262277
#rlpEncode(items: string[]): string {
263278
const encoded = items.map((item) => {
264279
if (item === '0x' || item === '') {

0 commit comments

Comments
 (0)