From 2ca41a31392b80ed9b391d5e1713f8123025d56b Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Tue, 3 Mar 2026 15:04:41 +1100 Subject: [PATCH 01/31] fix: hotfix release flow ships only hotfix commits and uses patch bump (#12022) * fix: hotfix release flow ships only hotfix commits and uses patch version bump The hotfix option in `yarn release` was shipping all commits since the last tag instead of just the hotfix commits. This happened because `getCommits()` used `${latestTag}..origin/${branch}` for hotfix branches, which includes develop history. - Use `origin/main..origin/${branch}` range for hotfix branches so only commits not on main are included - Add programmatic verification that the hotfix branch is actually based on origin/main (not just the user's confirmation) - Use patch version bump for hotfixes instead of minor - Detect hotfix vs regular release at merge time via PR title prefix Co-Authored-By: Claude Opus 4.6 * fix: redesign hotfix release to cherry-pick from develop instead of requiring manual branch Hotfix flow now fetches unreleased commits (origin/main..origin/develop), presents them as checkboxes, cherry-picks selected commits onto main, tags with patch version, and syncs private + develop - eliminating the need to manually create a branch off main. Co-Authored-By: Claude Opus 4.6 * fix: use raw git exec for getUnreleasedCommits to parse multi-line output correctly simple-git's .log() collapses custom --pretty=format output into a single entry. Switching to pify(exec) gives us raw stdout we can split by newline. Co-Authored-By: Claude Opus 4.6 * fix: handle pify(exec) return type safely in getUnreleasedCommits Co-Authored-By: Claude Opus 4.6 * fix: use simple-git format API correctly and harden cherry-pick loop Use simple-git's object-style format option instead of raw --pretty flag which broke internal parsing. Reverse cherry-pick order to oldest-first and add error handling with abort on failure. Co-Authored-By: Claude Opus 4.6 * fix: harden release script commit parsing and hotfix rollback * fix: print release commits one per line * fix: satisfy release script lint formatting * fix: satisfy release script lint formatting Co-Authored-By: Claude Opus 4.6 * fix: add defensive parsing guard and develop merge error handling Guard against empty commit subjects in getUnreleasedCommits where spaceIdx === -1 would corrupt the hash via slice(0, -1). Add try/catch around the merge-main-into-develop step in both hotfix and regular release flows so a conflict doesn't leave develop in a broken state with no recovery guidance. Co-Authored-By: Claude Opus 4.6 * fix: add missing await on doHotfixRelease() Without await, the script could exit mid-operation or swallow errors as unhandled rejections during cherry-picks, tags, pushes, and develop merges. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- scripts/release.ts | 227 ++++++++++++++++++++++++++++++--------------- 1 file changed, 150 insertions(+), 77 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 86f6f77d803..c8a3caa3d2a 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -40,21 +40,8 @@ const inquireReleaseType = async (): Promise => { return (await inquirer.prompt(questions)).releaseType } -const inquireCleanBranchOffMain = async (): Promise => { - const questions: inquirer.QuestionCollection<{ isCleanlyBranched: boolean }> = [ - { - type: 'confirm', - name: 'isCleanlyBranched', - message: 'Is your branch cleanly branched off origin/main?', - default: false, // Defaulting to false to encourage verification - }, - ] - const { isCleanlyBranched } = await inquirer.prompt(questions) - return isCleanlyBranched -} - const inquireProceedWithCommits = async (commits: string[], action: 'create' | 'merge') => { - console.log(chalk.blue(['', commits, ''].join('\n'))) + console.log(chalk.blue(['', ...commits, ''].join('\n'))) const message = action === 'create' ? 'Do you want to create a release with these commits?' @@ -366,41 +353,58 @@ const createDraftRegularPR = async (prBody: string, nextVersion: string): Promis exit(chalk.green(`Release ${nextVersion} created.`)) } -const createDraftHotfixPR = async (): Promise => { - const currentBranch = await git().revparse(['--abbrev-ref', 'HEAD']) - const { messages } = await getCommits(currentBranch as GetCommitMessagesArgs) - // TODO(0xdef1cafe): parse version bump from commit messages - const nextVersion = await getNextReleaseVersion('minor') - console.log(chalk.green('Creating draft hotfix PR...')) - await createDraftPR(`chore: hotfix release ${nextVersion}`, messages.join('\n')) - console.log(chalk.green('Draft hotfix PR created.')) - exit(chalk.green(`Hotfix release ${nextVersion} created.`)) -} - -type GetCommitMessagesArgs = 'develop' | 'release' -type GetCommitMessagesReturn = { +type GetCommitsReturn = { messages: string[] total: number } -type GetCommitMessages = (branch: GetCommitMessagesArgs) => Promise -const getCommits: GetCommitMessages = async branch => { - // Get the last release tag +const getCommits = async (branch: string): Promise => { const latestTag = await getLatestSemverTag() - - // If we have a last release tag, base the diff on that const range = latestTag ? `${latestTag}..origin/${branch}` : `origin/main..origin/${branch}` - const { all, total } = await git().log([ - '--oneline', - '--first-parent', - '--pretty=format:%s', // no hash, just conventional commit style - range, - ]) + const result = await pify(exec)(`git log --first-parent --pretty=format:"%s" ${range}`) + const stdout = typeof result === 'string' ? result : (result as { stdout: string }).stdout + const messages = stdout.trim().split('\n').filter(Boolean) - const messages = all.map(({ hash }) => hash) + const total = messages.length return { messages, total } } +type UnreleasedCommit = { hash: string; message: string } + +const getUnreleasedCommits = async (): Promise => { + const result = await pify(exec)( + 'git log --first-parent --pretty=format:"%H %s" origin/main..origin/develop', + ) + const stdout = typeof result === 'string' ? result : (result as { stdout: string }).stdout + + if (!stdout.trim()) return [] + + return stdout + .trim() + .split('\n') + .map(line => { + const spaceIdx = line.indexOf(' ') + if (spaceIdx === -1) return { hash: line, message: '' } + return { hash: line.slice(0, spaceIdx), message: line.slice(spaceIdx + 1) } + }) +} + +const inquireSelectCommits = async (commits: UnreleasedCommit[]): Promise => { + const { selected } = await inquirer.prompt<{ selected: string[] }>([ + { + type: 'checkbox', + name: 'selected', + message: 'Select commits to cherry-pick into the hotfix:', + choices: commits.map(c => ({ + name: `${c.hash.slice(0, 8)} ${c.message}`, + value: c.hash, + })), + }, + ]) + + return commits.filter(c => selected.includes(c.hash)) +} + const assertCommitsToRelease = (total: number) => { if (!total) exit(chalk.red('No commits to release.')) } @@ -467,53 +471,102 @@ const doRegularRelease = async () => { } const doHotfixRelease = async () => { - const currentBranch = await git().revparse(['--abbrev-ref', 'HEAD']) - const isMain = currentBranch === 'main' + const unreleased = await getUnreleasedCommits() + if (unreleased.length === 0) { + exit(chalk.red('No unreleased commits found between origin/main and origin/develop.')) + } - if (isMain) { - console.log( - chalk.red( - 'Cannot open hotfix PRs directly off local main branch for security reasons. Please branch out to another branch first.', - ), - ) - exit() + console.log(chalk.green(`Found ${unreleased.length} unreleased commit(s).\n`)) + const selected = await inquireSelectCommits(unreleased) + if (selected.length === 0) { + exit(chalk.yellow('No commits selected. Hotfix cancelled.')) } - // Only continue if the branch is cleanly branched off origin/main since we will - // target it in the hotfix PR - const isCleanOffMain = await inquireCleanBranchOffMain() - if (!isCleanOffMain) { - exit( - chalk.yellow( - 'Please ensure your branch is cleanly branched off origin/main before proceeding.', - ), - ) + console.log(chalk.blue('\nSelected commits:')) + for (const c of selected) { + console.log(chalk.blue(` ${c.hash.slice(0, 8)} ${c.message}`)) } + console.log() - // Dev has confirmed they're clean off main, here goes nothing - await fetch() + const { shouldProceed } = await inquirer.prompt<{ shouldProceed: boolean }>([ + { + type: 'confirm', + default: true, + name: 'shouldProceed', + message: 'Proceed with cherry-picking these commits onto main?', + }, + ]) + if (!shouldProceed) exit('Hotfix cancelled.') - // Force push current branch upstream so we can getCommits from it - getCommits uses upstream for diffing - console.log(chalk.green(`Force pushing ${currentBranch} branch...`)) - await git().push(['-u', 'origin', currentBranch, '--force']) - const { messages, total } = await getCommits(currentBranch as GetCommitMessagesArgs) - assertCommitsToRelease(total) - await inquireProceedWithCommits(messages, 'create') + console.log(chalk.green('Checking out main...')) + await git().checkout(['main']) + console.log(chalk.green('Pulling main...')) + await git().pull() + const mainSha = (await git().revparse(['HEAD'])).trim() - // Merge origin/main as a paranoia check - console.log(chalk.green('Merging origin/main...')) - await git().merge(['origin/main']) + const cherryPickOrder = [...selected].reverse() + for (const c of cherryPickOrder) { + console.log(chalk.green(`Cherry-picking ${c.hash.slice(0, 8)} ${c.message}...`)) + try { + await pify(exec)(`git cherry-pick ${c.hash}`) + } catch (err) { + try { + await pify(exec)('git cherry-pick --abort') + } catch { + // no-op + } + await git().reset(['--hard', mainSha]) + const message = err instanceof Error ? err.message : String(err) + const shortHash = c.hash.slice(0, 8) + const shortMainSha = mainSha.slice(0, 8) + exit( + chalk.red( + `Cherry-pick failed for ${shortHash}: ${message}\nMain has been reset to ${shortMainSha}.`, + ), + ) + } + } - console.log(chalk.green('Setting release to current branch...')) - await git().checkout(['-B', 'release']) + const nextVersion = await getNextReleaseVersion('patch') + console.log(chalk.green(`Tagging main with version ${nextVersion}`)) + await git().tag(['-a', nextVersion, '-m', nextVersion]) + console.log(chalk.green('Pushing main with tags...')) + await git().push(['origin', 'main', '--tags']) - console.log(chalk.green('Force pushing release branch...')) - await git().push(['--force', 'origin', 'release']) + console.log(chalk.green('Resetting private to main...')) + await git().checkout(['-B', 'private']) + console.log(chalk.green('Pushing private...')) + await git().push(['--force', 'origin', 'private', '--tags']) - console.log(chalk.green('Creating draft hotfix PR...')) - await createDraftHotfixPR() + console.log(chalk.green('Checking out develop...')) + await git().checkout(['develop']) + console.log(chalk.green('Pulling develop...')) + await git().pull() + const developSha = (await git().revparse(['HEAD'])).trim() + console.log(chalk.green('Merging main back into develop...')) + try { + await git().merge(['main']) + } catch (err) { + await git() + .merge(['--abort']) + .catch(() => {}) + await git().reset(['--hard', developSha]) + const message = err instanceof Error ? err.message : String(err) + exit( + chalk.red( + `Merge into develop failed: ${message}\n` + + `Hotfix ${nextVersion} was pushed to main but develop merge failed.\n` + + `Develop has been reset to ${developSha.slice( + 0, + 8, + )}. Please merge main into develop manually.`, + ), + ) + } + console.log(chalk.green('Pushing develop...')) + await git().push(['origin', 'develop']) - exit(chalk.green('Hotfix release process completed.')) + exit(chalk.green(`Hotfix release ${nextVersion} completed successfully.`)) } type WebReleaseType = Extract @@ -547,13 +600,14 @@ const isReleaseInProgress = async (): Promise => { } const createRelease = async () => { - ;(await inquireReleaseType()) === 'Regular' ? await doRegularRelease() : doHotfixRelease() + ;(await inquireReleaseType()) === 'Regular' ? await doRegularRelease() : await doHotfixRelease() } const mergeRelease = async () => { const { messages, total } = await getCommits('release') assertCommitsToRelease(total) await inquireProceedWithCommits(messages, 'merge') + console.log(chalk.green('Checking out release...')) await git().checkout(['release']) console.log(chalk.green('Pulling release...')) @@ -581,8 +635,27 @@ const mergeRelease = async () => { await git().checkout(['develop']) console.log(chalk.green('Pulling develop...')) await git().pull() + const developSha = (await git().revparse(['HEAD'])).trim() console.log(chalk.green('Merging main back into develop...')) - await git().merge(['main']) + try { + await git().merge(['main']) + } catch (err) { + await git() + .merge(['--abort']) + .catch(() => {}) + await git().reset(['--hard', developSha]) + const message = err instanceof Error ? err.message : String(err) + exit( + chalk.red( + `Merge into develop failed: ${message}\n` + + `Release ${nextVersion} was pushed to main but develop merge failed.\n` + + `Develop has been reset to ${developSha.slice( + 0, + 8, + )}. Please merge main into develop manually.`, + ), + ) + } console.log(chalk.green('Pushing develop...')) await git().push(['origin', 'develop']) exit(chalk.green(`Release ${nextVersion} completed successfully.`)) From 2a7fc49f0e7118534203377176a508962ec1db88 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Tue, 3 Mar 2026 11:04:14 +0100 Subject: [PATCH 02/31] fix: add bsc fallback rpc env urls (#12074) --- .env | 3 +++ headers/csps/chains/bnbsmartchain.ts | 3 +++ src/config.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.env b/.env index 7a356873073..f951601dac4 100644 --- a/.env +++ b/.env @@ -149,6 +149,9 @@ VITE_ETHEREUM_NODE_URL=https://api.ethereum.shapeshift.com/api/v1/jsonrpc VITE_AVALANCHE_NODE_URL=https://api.avalanche.shapeshift.com/api/v1/jsonrpc VITE_OPTIMISM_NODE_URL=https://api.optimism.shapeshift.com/api/v1/jsonrpc VITE_BNBSMARTCHAIN_NODE_URL=https://api.bnbsmartchain.shapeshift.com/api/v1/jsonrpc +VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_1=https://bsc-dataseed.binance.org/ +VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_2=https://bsc-dataseed1.ninicoin.io/ +VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_3=https://bsc-rpc.publicnode.com VITE_POLYGON_NODE_URL=https://api.polygon.shapeshift.com/api/v1/jsonrpc VITE_GNOSIS_NODE_URL=https://api.gnosis.shapeshift.com/api/v1/jsonrpc VITE_ARBITRUM_NODE_URL=https://api.arbitrum.shapeshift.com/api/v1/jsonrpc diff --git a/headers/csps/chains/bnbsmartchain.ts b/headers/csps/chains/bnbsmartchain.ts index 586360c315d..dbea77fd4e0 100644 --- a/headers/csps/chains/bnbsmartchain.ts +++ b/headers/csps/chains/bnbsmartchain.ts @@ -8,6 +8,9 @@ const env = loadEnv(mode, process.cwd(), '') export const csp: Csp = { 'connect-src': [ env.VITE_BNBSMARTCHAIN_NODE_URL, + env.VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_1, + env.VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_2, + env.VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_3, env.VITE_UNCHAINED_BNBSMARTCHAIN_HTTP_URL, env.VITE_UNCHAINED_BNBSMARTCHAIN_WS_URL, 'https://binance.llamarpc.com', diff --git a/src/config.ts b/src/config.ts index 7ce9515f59a..6d7c24e5d39 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,6 +55,9 @@ const validators = { VITE_AVALANCHE_NODE_URL: url(), VITE_OPTIMISM_NODE_URL: url(), VITE_BNBSMARTCHAIN_NODE_URL: url(), + VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_1: url({ default: '' }), + VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_2: url({ default: '' }), + VITE_BNBSMARTCHAIN_NODE_URL_FALLBACK_3: url({ default: '' }), VITE_POLYGON_NODE_URL: url(), VITE_GNOSIS_NODE_URL: url(), VITE_ARBITRUM_NODE_URL: url(), From c803fadb47ea9f91e17365f7a73dae3c22ed65c1 Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:37:18 +0100 Subject: [PATCH 03/31] fix: resolve 404 on terms of service and privacy policy links (#12069) --- src/pages/ConnectWallet/MobileConnect.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/pages/ConnectWallet/MobileConnect.tsx b/src/pages/ConnectWallet/MobileConnect.tsx index a3220cfcc7d..1730ce64722 100644 --- a/src/pages/ConnectWallet/MobileConnect.tsx +++ b/src/pages/ConnectWallet/MobileConnect.tsx @@ -18,7 +18,7 @@ import { useQuery as useReactQuery } from '@tanstack/react-query' import { AnimatePresence, motion } from 'framer-motion' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import { generatePath, matchPath, useNavigate } from 'react-router-dom' +import { generatePath, matchPath, NavLink, useNavigate } from 'react-router-dom' import { MobileWalletList } from './components/WalletList' @@ -324,21 +324,11 @@ export const MobileConnect = () => { mx='auto' > {translate('connectWalletPage.footerOne')}{' '} - + {translate('connectWalletPage.terms')} {' '} {translate('common.and')}{' '} - + {translate('connectWalletPage.privacyPolicy')} From 1a48e128e9d75fc2c08af2de80e23c78307be211 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Tue, 3 Mar 2026 12:25:46 +0100 Subject: [PATCH 04/31] feat: bitcoin wcv2 wallet support (#11913) --- packages/hdwallet-core/src/wallet.ts | 4 + .../hdwallet-walletconnectv2/package.json | 2 + .../hdwallet-walletconnectv2/src/bitcoin.ts | 253 ++++++++++++++++++ .../hdwallet-walletconnectv2/src/index.ts | 1 + .../src/walletconnectV2.ts | 174 +++++++++++- pnpm-lock.yaml | 6 + src/lib/account/utxo.ts | 11 +- 7 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 packages/hdwallet-walletconnectv2/src/bitcoin.ts diff --git a/packages/hdwallet-core/src/wallet.ts b/packages/hdwallet-core/src/wallet.ts index b47117ade2b..12a907971e9 100644 --- a/packages/hdwallet-core/src/wallet.ts +++ b/packages/hdwallet-core/src/wallet.ts @@ -417,6 +417,10 @@ export function isVultisig(wallet: HDWallet | null): boolean { return isObject(wallet) && (wallet as any)._isVultisig } +export function isWalletConnectV2(wallet: HDWallet | null): boolean { + return isObject(wallet) && (wallet as any)._isWalletConnectV2 +} + export interface HDWalletInfo { /** * Retrieve the wallet's vendor string. diff --git a/packages/hdwallet-walletconnectv2/package.json b/packages/hdwallet-walletconnectv2/package.json index 08c7cd9fa18..077e2dbcf4c 100644 --- a/packages/hdwallet-walletconnectv2/package.json +++ b/packages/hdwallet-walletconnectv2/package.json @@ -28,6 +28,8 @@ "postbuild:cjs": "echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json" }, "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", + "@shapeshiftoss/bitcoinjs-lib": "7.0.0-shapeshift.2", "@shapeshiftoss/hdwallet-core": "workspace:^", "@walletconnect/ethereum-provider": "^2.20.2", "@walletconnect/modal": "^2.6.2", diff --git a/packages/hdwallet-walletconnectv2/src/bitcoin.ts b/packages/hdwallet-walletconnectv2/src/bitcoin.ts new file mode 100644 index 00000000000..f67433e5e3b --- /dev/null +++ b/packages/hdwallet-walletconnectv2/src/bitcoin.ts @@ -0,0 +1,253 @@ +import ecc from '@bitcoinerlab/secp256k1' +import * as bitcoin from '@shapeshiftoss/bitcoinjs-lib' +import type { + BTCAccountPath, + BTCGetAccountPaths, + BTCGetAddress, + BTCSignedMessage, + BTCSignedTx, + BTCSignMessage, + BTCSignTx, + BTCVerifyMessage, + BTCWallet, + PathDescription, +} from '@shapeshiftoss/hdwallet-core' +import { BTCInputScriptType, describeUTXOPath, slip44ByCoin } from '@shapeshiftoss/hdwallet-core' +import type EthereumProvider from '@walletconnect/ethereum-provider' + +const BIP122_BITCOIN_MAINNET_CAIP2 = 'bip122:000000000019d6689c085ae165831e93' + +function extractAddressFromCaip10(caip10Account: string): string { + const parts = caip10Account.split(':') + return parts[parts.length - 1] +} + +export function describeBTCPath( + path: number[], + coin: string, + scriptType: BTCInputScriptType, +): PathDescription { + return describeUTXOPath(path, coin, scriptType) +} + +export function btcGetAccountPaths(msg: BTCGetAccountPaths): BTCAccountPath[] { + const slip44 = slip44ByCoin(msg.coin) + if (slip44 === undefined) return [] + const bip84 = { + coin: msg.coin, + scriptType: BTCInputScriptType.SpendWitness, + addressNList: [0x80000000 + 84, 0x80000000 + slip44, 0x80000000 + msg.accountIdx], + } + + const paths: BTCAccountPath[] = [] + + if (!msg.scriptType || msg.scriptType === BTCInputScriptType.SpendWitness) { + paths.push(bip84) + } + + return paths +} + +export function btcNextAccountPath(msg: BTCAccountPath): BTCAccountPath | undefined { + if (msg.scriptType !== BTCInputScriptType.SpendWitness) return undefined + const slip44 = slip44ByCoin(msg.coin) + if (slip44 === undefined) return undefined + + const accountIdx = msg.addressNList[2] & 0x7fffffff + + return { + coin: msg.coin, + scriptType: BTCInputScriptType.SpendWitness, + addressNList: [0x80000000 + 84, 0x80000000 + slip44, 0x80000000 + accountIdx + 1], + } +} + +export async function btcGetAddress( + provider: EthereumProvider, + _msg: BTCGetAddress, +): Promise { + try { + const session = provider.session + if (!session) return null + + const bip122Accounts = session.namespaces?.bip122?.accounts + if (!bip122Accounts || bip122Accounts.length === 0) return null + + return extractAddressFromCaip10(bip122Accounts[0]) + } catch (error) { + console.error(error) + return null + } +} + +function getNetwork(coin: string): bitcoin.networks.Network { + switch (coin.toLowerCase()) { + case 'bitcoin': + return bitcoin.networks.bitcoin + default: + throw new Error(`Unsupported coin: ${coin}`) + } +} + +async function addInput(psbt: bitcoin.Psbt, input: BTCSignTx['inputs'][number]): Promise { + switch (input.scriptType) { + case BTCInputScriptType.SpendWitness: { + psbt.addInput({ + hash: input.txid, + index: input.vout, + nonWitnessUtxo: Buffer.from(input.hex, 'hex'), + ...(input.sequence !== undefined && { sequence: input.sequence }), + }) + break + } + default: + throw new Error(`Unsupported script type: ${input.scriptType}`) + } +} + +async function addOutput( + wallet: BTCWallet, + psbt: bitcoin.Psbt, + output: BTCSignTx['outputs'][number], + coin: string, +): Promise { + if (!output.amount) throw new Error('Invalid output - missing amount.') + + const address = await (async () => { + if (output.address) return output.address + + if (output.addressNList) { + const outputAddress = await wallet.btcGetAddress({ + addressNList: output.addressNList, + coin, + showDisplay: false, + }) + if (!outputAddress) throw new Error('Could not get address from wallet') + return outputAddress + } + })() + + if (!address) throw new Error('Invalid output - no address') + + psbt.addOutput({ address, value: BigInt(output.amount) }) +} + +export async function btcSignTx( + wallet: BTCWallet, + provider: EthereumProvider, + msg: BTCSignTx, +): Promise { + try { + bitcoin.initEccLib(ecc) + + const session = provider.session + if (!session) return null + + const bip122Accounts = session.namespaces?.bip122?.accounts + if (!bip122Accounts || bip122Accounts.length === 0) return null + + const address = extractAddressFromCaip10(bip122Accounts[0]) + + const network = getNetwork(msg.coin) + const psbt = new bitcoin.Psbt({ network }) + + psbt.setVersion(msg.version ?? 2) + if (msg.locktime) { + psbt.setLocktime(msg.locktime) + } + + for (const input of msg.inputs) { + await addInput(psbt, input) + } + + for (const output of msg.outputs) { + await addOutput(wallet, psbt, output, msg.coin) + } + + if (msg.opReturnData) { + const data = Buffer.from(msg.opReturnData, 'utf-8') + const embed = bitcoin.payments.embed({ data: [data] }) + const script = embed.output + if (!script) throw new Error('unable to build OP_RETURN script') + psbt.addOutput({ script, value: BigInt(0) }) + } + + const psbtBase64 = psbt.toBase64() + + const signInputs = msg.inputs.map((_input, index) => ({ + address, + index, + sighashTypes: [bitcoin.Transaction.SIGHASH_ALL], + })) + + const result = await provider.signer.request<{ psbt: string; txid?: string }>( + { + method: 'signPsbt', + params: { + account: address, + psbt: psbtBase64, + signInputs, + broadcast: false, + }, + }, + BIP122_BITCOIN_MAINNET_CAIP2, + ) + + const signedPsbt = bitcoin.Psbt.fromBase64(result.psbt, { network }) + signedPsbt.finalizeAllInputs() + const tx = signedPsbt.extractTransaction() + + const signatures = signedPsbt.data.inputs.map(input => + input.partialSig ? Buffer.from(input.partialSig[0].signature).toString('hex') : '', + ) + + return { + signatures, + serializedTx: tx.toHex(), + } + } catch (error) { + console.error(error) + return null + } +} + +export async function btcSignMessage( + provider: EthereumProvider, + msg: BTCSignMessage, +): Promise { + try { + const session = provider.session + if (!session) return null + + const bip122Accounts = session.namespaces?.bip122?.accounts + if (!bip122Accounts || bip122Accounts.length === 0) return null + + const address = extractAddressFromCaip10(bip122Accounts[0]) + + const result = await provider.signer.request<{ signature: string; address: string }>( + { + method: 'signMessage', + params: { + account: address, + message: msg.message, + }, + }, + BIP122_BITCOIN_MAINNET_CAIP2, + ) + + return { + address: result.address, + signature: result.signature, + } + } catch (error) { + console.error(error) + return null + } +} + +export async function btcVerifyMessage( + _provider: EthereumProvider, + _msg: BTCVerifyMessage, +): Promise { + return null +} diff --git a/packages/hdwallet-walletconnectv2/src/index.ts b/packages/hdwallet-walletconnectv2/src/index.ts index 366cbbcff2d..417ec2faa27 100644 --- a/packages/hdwallet-walletconnectv2/src/index.ts +++ b/packages/hdwallet-walletconnectv2/src/index.ts @@ -1,2 +1,3 @@ export * from './adapter' +export * from './bitcoin' export * from './walletconnectV2' diff --git a/packages/hdwallet-walletconnectv2/src/walletconnectV2.ts b/packages/hdwallet-walletconnectv2/src/walletconnectV2.ts index 7c3256bc311..2b0fe6cab0d 100644 --- a/packages/hdwallet-walletconnectv2/src/walletconnectV2.ts +++ b/packages/hdwallet-walletconnectv2/src/walletconnectV2.ts @@ -1,6 +1,16 @@ import type { AddEthereumChainParameter, Address, + BTCAccountPath, + BTCGetAccountPaths, + BTCGetAddress, + BTCSignedMessage, + BTCSignedTx, + BTCSignMessage, + BTCSignTx, + BTCVerifyMessage, + BTCWallet, + BTCWalletInfo, Coin, DescribePath, ETHAccountPath, @@ -14,6 +24,7 @@ import type { ETHVerifyMessage, ETHWallet, ETHWalletInfo, + GetPublicKey, HDWallet, HDWalletInfo, PathDescription, @@ -21,10 +32,19 @@ import type { Pong, PublicKey, } from '@shapeshiftoss/hdwallet-core' -import { slip44ByCoin } from '@shapeshiftoss/hdwallet-core' +import { BTCInputScriptType, slip44ByCoin } from '@shapeshiftoss/hdwallet-core' import type EthereumProvider from '@walletconnect/ethereum-provider' import isObject from 'lodash/isObject' +import { + btcGetAccountPaths, + btcGetAddress, + btcNextAccountPath, + btcSignMessage, + btcSignTx, + btcVerifyMessage, + describeBTCPath, +} from './bitcoin' import { describeETHPath, ethGetAddress, @@ -35,6 +55,12 @@ import { ethVerifyMessage, } from './ethereum' +const BIP122_OPTIONAL_NAMESPACE = { + chains: ['bip122:000000000019d6689c085ae165831e93'], + methods: ['sendTransfer', 'signPsbt', 'signMessage', 'getAccountAddresses'], + events: ['bip122_addressesChanged'], +} + export function isWalletConnectV2(wallet: HDWallet): wallet is WalletConnectV2HDWallet { return isObject(wallet) && (wallet as any)._isWalletConnectV2 } @@ -51,9 +77,9 @@ export function isWalletConnectV2(wallet: HDWallet): wallet is WalletConnectV2HD * - eth_sendRawTransaction * @see https://specs.walletconnect.com/2.0/blockchain-rpc/ethereum-rpc */ -export class WalletConnectV2WalletInfo implements HDWalletInfo, ETHWalletInfo { +export class WalletConnectV2WalletInfo implements HDWalletInfo, ETHWalletInfo, BTCWalletInfo { readonly _supportsETHInfo = true - readonly _supportsBTCInfo = false + readonly _supportsBTCInfo = true public getVendor(): string { return 'WalletConnectV2' } @@ -94,6 +120,12 @@ export class WalletConnectV2WalletInfo implements HDWalletInfo, ETHWalletInfo { switch (msg.coin) { case 'Ethereum': return describeETHPath(msg.path) + case 'Bitcoin': + return describeBTCPath( + msg.path, + msg.coin, + msg.scriptType ?? BTCInputScriptType.SpendWitness, + ) default: throw new Error('Unsupported path') } @@ -131,13 +163,40 @@ export class WalletConnectV2WalletInfo implements HDWalletInfo, ETHWalletInfo { }, ] } + + public async btcSupportsCoin(coin: Coin): Promise { + return coin === 'Bitcoin' + } + + public async btcSupportsScriptType( + coin: Coin, + scriptType?: BTCInputScriptType, + ): Promise { + if (coin !== 'Bitcoin') return false + return scriptType === undefined || scriptType === BTCInputScriptType.SpendWitness + } + + public async btcSupportsSecureTransfer(): Promise { + return false + } + + public btcSupportsNativeShapeShift(): boolean { + return false + } + + public btcGetAccountPaths(msg: BTCGetAccountPaths): BTCAccountPath[] { + return btcGetAccountPaths(msg) + } + + public btcNextAccountPath(msg: BTCAccountPath): BTCAccountPath | undefined { + return btcNextAccountPath(msg) + } } -export class WalletConnectV2HDWallet implements HDWallet, ETHWallet { +export class WalletConnectV2HDWallet implements HDWallet, ETHWallet, BTCWallet { readonly _supportsETH = true readonly _supportsETHInfo = true - readonly _supportsBTCInfo = false - readonly _supportsBTC = false + readonly _supportsBTCInfo = true readonly _isWalletConnectV2 = true readonly _supportsEthSwitchChain = true readonly _supportsAvalanche = true @@ -181,10 +240,30 @@ export class WalletConnectV2HDWallet implements HDWallet, ETHWallet { chainId: number | undefined accounts: string[] = [] ethAddress: Address | undefined + btcAddress: string | undefined + + get _supportsBTC(): boolean { + return !!this.provider.session?.namespaces?.bip122 + } constructor(provider: EthereumProvider) { this.provider = provider this.info = new WalletConnectV2WalletInfo() + this.patchSignerForNonEvmNamespaces() + } + + private patchSignerForNonEvmNamespaces(): void { + const signer = this.provider.signer + const originalConnect = signer.connect.bind(signer) + signer.connect = async (params: Parameters[0]) => { + return originalConnect({ + ...params, + optionalNamespaces: { + ...params.optionalNamespaces, + bip122: BIP122_OPTIONAL_NAMESPACE, + }, + }) + } } async getFeatures(): Promise> { @@ -299,9 +378,25 @@ export class WalletConnectV2HDWallet implements HDWallet, ETHWallet { return this.info.describePath(msg) } - public async getPublicKeys(): Promise<(PublicKey | null)[]> { - // Ethereum public keys are not exposed by the RPC API - return [] + public async getPublicKeys(msg: GetPublicKey[]): Promise<(PublicKey | null)[]> { + return await Promise.all( + msg.map(async getPublicKey => { + const { coin, scriptType } = getPublicKey + + if (coin === 'Bitcoin' && scriptType === BTCInputScriptType.SpendWitness) { + const address = await this.btcGetAddress({ + coin, + addressNList: getPublicKey.addressNList, + scriptType, + showDisplay: false, + } as BTCGetAddress) + if (!address) return null + return { xpub: address } + } + + return null + }), + ) } public async isInitialized(): Promise { @@ -403,7 +498,13 @@ export class WalletConnectV2HDWallet implements HDWallet, ETHWallet { } public async getDeviceID(): Promise { - return 'wc:' + (await this.ethGetAddress()) + const ethAddr = await this.ethGetAddress() + if (ethAddr) return 'wc:' + ethAddr + + const btcAddr = await this.btcGetAddress({ coin: 'Bitcoin' } as BTCGetAddress) + if (btcAddr) return 'wc:' + btcAddr + + return 'wc:unknown' } public async getFirmwareVersion(): Promise { @@ -427,4 +528,57 @@ export class WalletConnectV2HDWallet implements HDWallet, ETHWallet { this.chainId = parsedChainId } + + // -- BTC Methods -- + + public async btcSupportsCoin(coin: Coin): Promise { + return this.info.btcSupportsCoin(coin) + } + + public async btcSupportsScriptType( + coin: Coin, + scriptType?: BTCInputScriptType, + ): Promise { + return this.info.btcSupportsScriptType(coin, scriptType) + } + + public async btcSupportsSecureTransfer(): Promise { + return this.info.btcSupportsSecureTransfer() + } + + public btcSupportsNativeShapeShift(): boolean { + return this.info.btcSupportsNativeShapeShift() + } + + public btcGetAccountPaths(msg: BTCGetAccountPaths): BTCAccountPath[] { + return this.info.btcGetAccountPaths(msg) + } + + public btcNextAccountPath(msg: BTCAccountPath): BTCAccountPath | undefined { + return this.info.btcNextAccountPath(msg) + } + + public async btcGetAddress(msg: BTCGetAddress): Promise { + if (this.btcAddress) { + return this.btcAddress + } + const address = await btcGetAddress(this.provider, msg) + if (address) { + this.btcAddress = address + return address + } + return null + } + + public async btcSignTx(msg: BTCSignTx): Promise { + return btcSignTx(this, this.provider, msg) + } + + public async btcSignMessage(msg: BTCSignMessage): Promise { + return btcSignMessage(this.provider, msg) + } + + public async btcVerifyMessage(msg: BTCVerifyMessage): Promise { + return btcVerifyMessage(this.provider, msg) + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61c9fb71c27..e3f99558f3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1858,6 +1858,12 @@ importers: packages/hdwallet-walletconnectv2: dependencies: + '@bitcoinerlab/secp256k1': + specifier: ^1.2.0 + version: 1.2.0 + '@shapeshiftoss/bitcoinjs-lib': + specifier: 7.0.0-shapeshift.2 + version: 7.0.0-shapeshift.2(typescript@5.2.2) '@shapeshiftoss/hdwallet-core': specifier: workspace:^ version: link:../hdwallet-core diff --git a/src/lib/account/utxo.ts b/src/lib/account/utxo.ts index 879d813410b..7a99b4509cb 100644 --- a/src/lib/account/utxo.ts +++ b/src/lib/account/utxo.ts @@ -2,7 +2,13 @@ import type { ChainId } from '@shapeshiftoss/caip' import { toAccountId } from '@shapeshiftoss/caip' import { utxoChainIds } from '@shapeshiftoss/chain-adapters' import type { HDWallet } from '@shapeshiftoss/hdwallet-core' -import { isMetaMask, isPhantom, isVultisig, supportsBTC } from '@shapeshiftoss/hdwallet-core/wallet' +import { + isMetaMask, + isPhantom, + isVultisig, + isWalletConnectV2, + supportsBTC, +} from '@shapeshiftoss/hdwallet-core/wallet' import { isTrezor } from '@shapeshiftoss/hdwallet-trezor' import type { AccountMetadataById, UtxoChainId } from '@shapeshiftoss/types' import { UtxoAccountType } from '@shapeshiftoss/types' @@ -110,8 +116,7 @@ export const deriveUtxoAccountIdsAndMetadata: DeriveAccountIdsAndMetadata = asyn // MetaMask snaps adapter only supports legacy for BTC and LTC supportedAccountTypes = [UtxoAccountType.P2pkh] } - if (isPhantom(wallet) || isVultisig(wallet)) { - // Phantom supposedly supports more script types, but only supports Segwit Native (bech32 addresses) for now + if (isPhantom(wallet) || isVultisig(wallet) || isWalletConnectV2(wallet)) { supportedAccountTypes = [UtxoAccountType.SegwitNative] } for (const accountType of supportedAccountTypes) { From cf2c8ed53ce6706c92225ba5b56d48002aed1498 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Tue, 3 Mar 2026 13:09:15 +0100 Subject: [PATCH 05/31] feat: bitcoin wc dapps support (#11916) --- packages/hdwallet-native/package.json | 1 + packages/hdwallet-native/src/bitcoin.test.ts | 8 +- packages/hdwallet-native/src/bitcoin.ts | 50 ++- pnpm-lock.yaml | 3 + src/assets/translations/en/main.json | 10 +- .../WalletConnectModalManager.tsx | 57 +++ .../WalletConnectModalSigningFooter.tsx | 47 +- .../content/BitcoinPsbtContent.tsx | 192 +++++++++ .../content/BitcoinSendTransferContent.tsx | 66 +++ .../modals/BitcoinSignConfirmation.tsx | 78 ++++ .../useWalletConnectEventsHandler.ts | 33 ++ .../useWalletConnectEventsManager.ts | 5 +- .../hooks/useWalletConnectState.ts | 11 +- .../walletConnectToDapps/typeGuards.ts | 9 + src/plugins/walletConnectToDapps/types.ts | 68 ++- src/plugins/walletConnectToDapps/utils.ts | 10 + .../utils/BIP122RequestHandlerUtil.test.ts | 406 ++++++++++++++++++ .../utils/BIP122RequestHandlerUtil.ts | 315 ++++++++++++++ .../utils/createApprovalNamespaces.ts | 163 ++++++- .../utils/parsePsbt.test.ts | 71 +++ .../walletConnectToDapps/utils/parsePsbt.ts | 57 +++ 21 files changed, 1625 insertions(+), 35 deletions(-) create mode 100644 src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinPsbtContent.tsx create mode 100644 src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinSendTransferContent.tsx create mode 100644 src/plugins/walletConnectToDapps/components/modals/BitcoinSignConfirmation.tsx create mode 100644 src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.test.ts create mode 100644 src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.ts create mode 100644 src/plugins/walletConnectToDapps/utils/parsePsbt.test.ts create mode 100644 src/plugins/walletConnectToDapps/utils/parsePsbt.ts diff --git a/packages/hdwallet-native/package.json b/packages/hdwallet-native/package.json index bdf45e58ddb..4468c180eb1 100644 --- a/packages/hdwallet-native/package.json +++ b/packages/hdwallet-native/package.json @@ -51,6 +51,7 @@ "bech32": "^1.1.4", "bip32": "^2.0.5", "bip39": "^3.0.2", + "bitcoinjs-message": "^2.1.0", "bs58": "^4.0.1", "bs58check": "^4.0.0", "crypto-js": "^4.2.0", diff --git a/packages/hdwallet-native/src/bitcoin.test.ts b/packages/hdwallet-native/src/bitcoin.test.ts index 8d82ff2181f..bfd6ba26081 100644 --- a/packages/hdwallet-native/src/bitcoin.test.ts +++ b/packages/hdwallet-native/src/bitcoin.test.ts @@ -504,14 +504,18 @@ describe('NativeBTCWallet', () => { await expect(wallet.btcSignTx(input as any)).rejects.toThrowError('Can not sign for this input') }) - it("doesn't support signing messages", async () => { + it('should sign messages', async () => { await expect( wallet.btcSignMessage({ coin: 'Bitcoin', addressNList: core.bip32ToAddressNList("m/44'/0'/0'/0/0"), message: 'foobar', }), - ).rejects.toThrowError('not implemented') + ).resolves.toEqual({ + address: '1JAd7XCBzGudGpJQSDSfpmJhiygtLQWaGL', + signature: + '20334a3da1ff11887fc53d7e68e8b3e47c07fc0e127e78fe82b778941e88a99051691161a3b57a4e405932257bb8f75d2b5628650b3f6c755647cbb31ff89041e6', + }) }) it("doesn't support verifying messages", async () => { diff --git a/packages/hdwallet-native/src/bitcoin.ts b/packages/hdwallet-native/src/bitcoin.ts index 7f4534b29f0..baefeea374f 100644 --- a/packages/hdwallet-native/src/bitcoin.ts +++ b/packages/hdwallet-native/src/bitcoin.ts @@ -1,8 +1,10 @@ import * as bitcoin from '@shapeshiftoss/bitcoinjs-lib' import * as core from '@shapeshiftoss/hdwallet-core' import * as bchAddr from 'bchaddrjs' +import * as bitcoinMsg from 'bitcoinjs-message' import type * as Isolation from './crypto/isolation' +import { SecP256K1 } from './crypto/isolation/core' import type { NativeHDWalletBase } from './native' import * as util from './util' @@ -354,9 +356,51 @@ export function MixinNativeBTCWallet { - throw new Error('function not implemented') + async btcSignMessage(msg: core.BTCSignMessage): Promise { + const result = await this.needsMnemonic(!!this.#masterKey, async () => { + const { addressNList, coin, message } = msg + const scriptType = msg.scriptType ?? core.BTCInputScriptType.SpendAddress + + const keyPair = await util.getKeyPair(this.#masterKey!, addressNList, coin, scriptType) + + const { address } = core.createPayment(keyPair.publicKey, keyPair.network, scriptType) + if (!address) throw new Error('Could not derive address') + + const signer: bitcoinMsg.SignerAsync = { + sign: async (hash: Buffer): Promise<{ signature: Buffer; recovery: number }> => { + const recoverableSig = await SecP256K1.RecoverableSignature.signCanonically( + keyPair.node, + null, + hash, + ) + return { + signature: Buffer.from(recoverableSig.slice(0, 64)), + recovery: recoverableSig[64], + } + }, + } + + const sigOptions: bitcoinMsg.SignatureOptions | undefined = (() => { + switch (scriptType) { + case core.BTCInputScriptType.SpendWitness: + case core.BTCInputScriptType.Bech32: + return { segwitType: 'p2wpkh' as const } + case core.BTCInputScriptType.SpendP2SHWitness: + return { segwitType: 'p2sh(p2wpkh)' as const } + default: + return undefined + } + })() + + const signedMsg = await bitcoinMsg.signAsync(message, signer, true, sigOptions) + + return { + address, + signature: signedMsg.toString('hex'), + } + }) + if (!result) throw new Error('Mnemonic required') + return result } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3f99558f3c..52edf9cb239 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1522,6 +1522,9 @@ importers: bip39: specifier: ^3.0.2 version: 3.1.0 + bitcoinjs-message: + specifier: ^2.1.0 + version: 2.2.0 bs58: specifier: ^4.0.1 version: 4.0.1 diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 2de8453d9de..cac6557310f 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -180,6 +180,7 @@ "expandRow": "Expand Row" }, "featureDisabled": "This feature is temporarily disabled.", + "no": "No", "yes": "Yes", "activeAccount": "Active Account", "selectAccount": "Select Account", @@ -2254,7 +2255,14 @@ "placeholder": "Gas Limit...", "tooltip": "The gas limit refers to the maximum amount of gas a user can consume to conduct a transaction." } - } + }, + "recipientAddress": "Recipient Address", + "amount": "Amount", + "amountSats": "%{amount} sats", + "psbt": "PSBT", + "broadcast": "Broadcast", + "inputs": "Inputs (%{count})", + "outputs": "Outputs (%{count})" }, "connect": { "title": "Connect your wallet to a dApp via WalletConnect and trigger transactions", diff --git a/src/plugins/walletConnectToDapps/WalletConnectModalManager.tsx b/src/plugins/walletConnectToDapps/WalletConnectModalManager.tsx index 174a380e23c..20741d47756 100644 --- a/src/plugins/walletConnectToDapps/WalletConnectModalManager.tsx +++ b/src/plugins/walletConnectToDapps/WalletConnectModalManager.tsx @@ -11,6 +11,8 @@ import { Dialog } from '@/components/Modal/components/Dialog' import { useWallet } from '@/hooks/useWallet/useWallet' import { assertUnreachable } from '@/lib/utils' import { assertGetEvmChainAdapter } from '@/lib/utils/evm' +import { assertGetUtxoChainAdapter } from '@/lib/utils/utxo' +import { BitcoinSignConfirmationModal } from '@/plugins/walletConnectToDapps/components/modals/BitcoinSignConfirmation' import { CosmosSignMessageConfirmationModal } from '@/plugins/walletConnectToDapps/components/modals/CosmosSignMessageConfirmation' import { EIP155SignMessageConfirmationModal } from '@/plugins/walletConnectToDapps/components/modals/EIP155SignMessageConfirmation' import { EIP155SignTypedDataConfirmation } from '@/plugins/walletConnectToDapps/components/modals/EIP155SignTypedDataConfirmation' @@ -23,6 +25,9 @@ import { SessionProposalModal } from '@/plugins/walletConnectToDapps/components/ import { SessionProposalRoutes } from '@/plugins/walletConnectToDapps/components/modals/SessionProposalRoutes' import { useWalletConnectState } from '@/plugins/walletConnectToDapps/hooks/useWalletConnectState' import type { + BIP122SendTransferCallRequest, + BIP122SignMessageCallRequest, + BIP122SignPsbtCallRequest, CosmosSignAminoCallRequest, CustomTransactionData, EthSendTransactionCallRequest, @@ -35,6 +40,7 @@ import type { WalletConnectState, } from '@/plugins/walletConnectToDapps/types' import { WalletConnectActionType, WalletConnectModal } from '@/plugins/walletConnectToDapps/types' +import { approveBIP122Request } from '@/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil' import { approveCosmosRequest } from '@/plugins/walletConnectToDapps/utils/CosmosRequestHandlerUtil' import { approveEIP155Request } from '@/plugins/walletConnectToDapps/utils/EIP155RequestHandlerUtil' import { approveSessionAuthRequest } from '@/plugins/walletConnectToDapps/utils/SessionAuthRequestHandlerUtil' @@ -148,6 +154,37 @@ export const WalletConnectModalManager: FC = ({ handleClose() }, [accountMetadata, handleClose, requestEvent, topic, wallet, web3wallet]) + const handleConfirmBIP122Request = useCallback(async () => { + if (!requestEvent || !wallet || !web3wallet || !topic || !chainId) return + + try { + const utxoChainAdapter = (() => { + try { + return assertGetUtxoChainAdapter(chainId) + } catch { + return undefined + } + })() + + const response = await approveBIP122Request({ + wallet, + requestEvent, + chainAdapter: utxoChainAdapter, + }) + await web3wallet.respondSessionRequest({ + topic, + response, + }) + } catch (e) { + console.error('[WC BIP122] request failed:', e) + await web3wallet.respondSessionRequest({ + topic, + response: formatJsonRpcError(requestEvent.id, (e as Error).message ?? 'Unknown error'), + }) + } + handleClose() + }, [chainId, handleClose, requestEvent, topic, wallet, web3wallet]) + const handleRejectRequest = useCallback(async () => { if (!requestEvent || !web3wallet || !topic) return @@ -220,6 +257,7 @@ export const WalletConnectModalManager: FC = ({ case WalletConnectModal.SignEIP155TransactionConfirmation: case WalletConnectModal.SendEIP155TransactionConfirmation: case WalletConnectModal.SendCosmosTransactionConfirmation: + case WalletConnectModal.SendBitcoinTransactionConfirmation: await handleRejectRequest() break case WalletConnectModal.NoAccountsForChain: @@ -318,6 +356,24 @@ export const WalletConnectModalManager: FC = ({ topic={topic} /> ) + case WalletConnectModal.SendBitcoinTransactionConfirmation: + if (!topic) return null + return ( + + > + } + topic={topic} + /> + ) case WalletConnectModal.NoAccountsForChain: return default: @@ -328,6 +384,7 @@ export const WalletConnectModalManager: FC = ({ dispatch, handleClose, handleConfirmSessionAuth, + handleConfirmBIP122Request, handleConfirmCosmosRequest, handleConfirmEIP155Request, handleRejectRequestAndClose, diff --git a/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/WalletConnectModalSigningFooter.tsx b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/WalletConnectModalSigningFooter.tsx index 6db97ed1fe0..0569da663e9 100644 --- a/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/WalletConnectModalSigningFooter.tsx +++ b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/WalletConnectModalSigningFooter.tsx @@ -1,6 +1,7 @@ import { Button, HStack, Image, VStack } from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' -import { fromAccountId } from '@shapeshiftoss/caip' +import { btcChainId, fromAccountId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' import type { FC } from 'react' import { useCallback, useMemo } from 'react' import type { UseFormReturn } from 'react-hook-form' @@ -12,11 +13,15 @@ import { GasSelectionMenu } from './GasSelectionMenu' import { Amount } from '@/components/Amount/Amount' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { RawText } from '@/components/Text' +import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { fromBaseUnit } from '@/lib/math' import { useSimulateEvmTransaction } from '@/plugins/walletConnectToDapps/hooks/useSimulateEvmTransaction' import type { CustomTransactionData, TransactionParams } from '@/plugins/walletConnectToDapps/types' import { selectAssetById, selectFeeAssetByChainId, + selectMarketDataByAssetIdUserCurrency, selectPortfolioCryptoBalanceByFilter, selectPortfolioUserCurrencyBalanceByFilter, } from '@/state/slices/selectors' @@ -45,6 +50,42 @@ const WalletConnectSigningWithSection: React.FC { + const adapter = getChainAdapterManager().get(btcChainId) + if (!adapter) return '0' + const account = await adapter.getAccount(userAddress) + return account.balance + }, + enabled: isBtcWcAccount, + staleTime: 30_000, + }) + + const btcMarketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, feeAssetId ?? ''), + ) + + const displayBalanceCryptoPrecision = useMemo(() => { + if (!isBtcWcAccount) return feeAssetBalanceCryptoPrecision + return fromBaseUnit(btcAddressBalance ?? '0', feeAsset?.precision ?? 8) + }, [isBtcWcAccount, feeAssetBalanceCryptoPrecision, btcAddressBalance, feeAsset?.precision]) + + const displayBalanceUserCurrency = useMemo(() => { + if (!isBtcWcAccount) return feeAssetBalanceUserCurrency + return bnOrZero(fromBaseUnit(btcAddressBalance ?? '0', feeAsset?.precision ?? 8)) + .times(bnOrZero(btcMarketData?.price)) + .toFixed(2) + }, [ + isBtcWcAccount, + feeAssetBalanceUserCurrency, + btcAddressBalance, + feeAsset?.precision, + btcMarketData?.price, + ]) + const networkIcon = useMemo(() => { return feeAsset?.networkIcon ?? feeAsset?.icon }, [feeAsset?.networkIcon, feeAsset?.icon]) @@ -64,13 +105,13 @@ const WalletConnectSigningWithSection: React.FC = ({ psbt, broadcast }) => { + const translate = useTranslate() + const sectionBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') + const [isDetailExpanded, toggleIsDetailExpanded] = useToggle(false) + + const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, btcChainId)) + const networkIcon = useMemo( + () => feeAsset?.networkIcon ?? feeAsset?.icon, + [feeAsset?.networkIcon, feeAsset?.icon], + ) + const precision = feeAsset?.precision ?? 8 + const symbol = feeAsset?.symbol ?? 'BTC' + + const parsed = useMemo(() => parsePsbt(psbt), [psbt]) + + const transactionDataJson = useMemo(() => { + if (!parsed) return null + return JSON.stringify( + { + version: parsed.version, + locktime: parsed.locktime, + inputs: parsed.inputs.map(input => ({ + txid: input.txid, + vout: input.vout, + address: input.address, + value: input.value, + })), + outputs: parsed.outputs.map(output => ({ + address: output.address, + value: output.value, + })), + }, + null, + 2, + ) + }, [parsed]) + + const hoverStyle = useMemo(() => ({ bg: 'transparent' }), []) + + if (!parsed) { + const truncated = psbt.length > 200 ? `${psbt.substring(0, 200)}...` : psbt + return ( + + + {truncated} + + + ) + } + + return ( + + + {feeAsset && ( + + + {translate('common.network')} + + + + {feeAsset.networkName || feeAsset.name} + + {networkIcon && } + + + )} + + + {translate('plugins.walletConnectToDapps.modal.sendTransaction.inputs', { + count: parsed.inputs.length, + })} + + {parsed.inputs.map((input, i) => ( + + {input.address ? ( + + + + ) : ( + + {`${input.txid.substring(0, 8)}...:${input.vout}`} + + )} + + + ))} + + + {translate('plugins.walletConnectToDapps.modal.sendTransaction.outputs', { + count: parsed.outputs.length, + })} + + {parsed.outputs.map((output, i) => ( + + {output.address ? ( + + + + ) : ( + + OP_RETURN + + )} + + + ))} + + {broadcast !== undefined && ( + + + {translate('plugins.walletConnectToDapps.modal.sendTransaction.broadcast')} + + + {translate(broadcast ? 'common.yes' : 'common.no')} + + + )} + + + + {isDetailExpanded && transactionDataJson && ( + + {transactionDataJson} + + )} + + + + ) +} diff --git a/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinSendTransferContent.tsx b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinSendTransferContent.tsx new file mode 100644 index 00000000000..8f1e20f8eaf --- /dev/null +++ b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinSendTransferContent.tsx @@ -0,0 +1,66 @@ +import { Card, HStack, VStack } from '@chakra-ui/react' +import { btcChainId } from '@shapeshiftoss/caip' +import type { FC } from 'react' +import { useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { RawText } from '@/components/Text' +import { fromBaseUnit } from '@/lib/math' +import { ExpandableCell } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/StructuredMessage/ExpandableCell' +import { selectFeeAssetByChainId } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type BitcoinSendTransferContentProps = { + recipientAddress: string + amount: string + memo?: string +} + +export const BitcoinSendTransferContent: FC = ({ + recipientAddress, + amount, + memo, +}) => { + const translate = useTranslate() + const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, btcChainId)) + + const precision = feeAsset?.precision ?? 8 + const symbol = feeAsset?.symbol ?? 'BTC' + + const amountCryptoPrecision = useMemo(() => fromBaseUnit(amount, precision), [amount, precision]) + + return ( + + + + + {translate('plugins.walletConnectToDapps.modal.sendTransaction.recipientAddress')} + + + + + + {translate('plugins.walletConnectToDapps.modal.sendTransaction.amount')} + + + + {memo && ( + + + {translate('plugins.walletConnectToDapps.modal.signMessage.memo')} + + + {memo} + + + )} + + + ) +} diff --git a/src/plugins/walletConnectToDapps/components/modals/BitcoinSignConfirmation.tsx b/src/plugins/walletConnectToDapps/components/modals/BitcoinSignConfirmation.tsx new file mode 100644 index 00000000000..6cb6a98956a --- /dev/null +++ b/src/plugins/walletConnectToDapps/components/modals/BitcoinSignConfirmation.tsx @@ -0,0 +1,78 @@ +import type { FC } from 'react' +import { useCallback, useMemo } from 'react' + +import { BitcoinPsbtContent } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinPsbtContent' +import { BitcoinSendTransferContent } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/BitcoinSendTransferContent' +import { MessageContent } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/MessageContent' +import { WalletConnectSigningModal } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/WalletConnectSigningModal' +import { useWalletConnectState } from '@/plugins/walletConnectToDapps/hooks/useWalletConnectState' +import type { + BIP122SendTransferCallRequest, + BIP122SendTransferCallRequestParams, + BIP122SignMessageCallRequest, + BIP122SignMessageCallRequestParams, + BIP122SignPsbtCallRequest, + BIP122SignPsbtCallRequestParams, + CustomTransactionData, +} from '@/plugins/walletConnectToDapps/types' +import { BIP122SigningMethod } from '@/plugins/walletConnectToDapps/types' +import type { WalletConnectRequestModalProps } from '@/plugins/walletConnectToDapps/WalletConnectModalManager' + +const BitcoinContent: FC<{ request: { method: string; params: Record } }> = ({ + request, +}) => { + const content = useMemo(() => { + switch (request.method) { + case BIP122SigningMethod.BIP122_SIGN_MESSAGE: { + const { message } = request.params as unknown as BIP122SignMessageCallRequestParams + return + } + case BIP122SigningMethod.BIP122_SEND_TRANSFER: { + const { recipientAddress, amount, memo } = + request.params as unknown as BIP122SendTransferCallRequestParams + return ( + + ) + } + case BIP122SigningMethod.BIP122_SIGN_PSBT: { + const { psbt, broadcast } = request.params as unknown as BIP122SignPsbtCallRequestParams + return + } + default: + return null + } + }, [request.method, request.params]) + + return content +} + +export const BitcoinSignConfirmationModal: FC< + WalletConnectRequestModalProps< + BIP122SendTransferCallRequest | BIP122SignPsbtCallRequest | BIP122SignMessageCallRequest + > +> = ({ onConfirm, onReject, state, topic }) => { + const { method } = useWalletConnectState(state) + const request = state.modalData.requestEvent?.params.request + + const handleFormSubmit = useCallback( + (formData?: CustomTransactionData) => onConfirm(formData), + [onConfirm], + ) + + if (!request || !method) return null + + return ( + + + + ) +} diff --git a/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts b/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts index 2cc53bb23a2..33bfba462e5 100644 --- a/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts +++ b/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts @@ -12,11 +12,16 @@ import type { WalletSwitchEthereumChainParams, } from '@/plugins/walletConnectToDapps/types' import { + BIP122SigningMethod, CosmosSigningMethod, EIP155_SigningMethod, WalletConnectActionType, WalletConnectModal, } from '@/plugins/walletConnectToDapps/types' +import { + deriveAddressFromExtPubKey, + isExtPubKey, +} from '@/plugins/walletConnectToDapps/utils/createApprovalNamespaces' export const useWalletConnectEventsHandler = ( dispatch: WalletConnectContextType['dispatch'], @@ -182,6 +187,34 @@ export const useWalletConnectEventsHandler = ( }, }) + case BIP122SigningMethod.BIP122_SEND_TRANSFER: + case BIP122SigningMethod.BIP122_SIGN_PSBT: + case BIP122SigningMethod.BIP122_SIGN_MESSAGE: + return dispatch({ + type: WalletConnectActionType.SET_MODAL, + payload: { + modal: WalletConnectModal.SendBitcoinTransactionConfirmation, + data: { requestEvent, requestSession: getRequestSession() }, + }, + }) + + case BIP122SigningMethod.BIP122_GET_ACCOUNT_ADDRESSES: { + const bip122Accounts = session?.namespaces?.bip122?.accounts ?? [] + const addresses = bip122Accounts.map(caip10 => { + const { account } = fromAccountId(caip10) + try { + const address = isExtPubKey(account) ? deriveAddressFromExtPubKey(account) : account + return { address } + } catch { + return { address: account } + } + }) + return web3wallet?.respondSessionRequest({ + topic, + response: formatJsonRpcResult(requestEvent.id, addresses), + }) + } + default: return } diff --git a/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsManager.ts b/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsManager.ts index 7c494094aec..110585ac62c 100644 --- a/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsManager.ts +++ b/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsManager.ts @@ -10,6 +10,7 @@ import type { WalletConnectState, } from '@/plugins/walletConnectToDapps/types' import { + BIP122SigningMethod, CosmosSigningMethod, EIP155_SigningMethod, WalletConnectActionType, @@ -24,6 +25,7 @@ export const isSupportedSessionRequest = ( const supportedMethods = [ ...Object.values(EIP155_SigningMethod), ...Object.values(CosmosSigningMethod), + ...Object.values(BIP122SigningMethod), ] return supportedMethods.some(value => value === request.params.request.method) } @@ -51,7 +53,8 @@ export const useWalletConnectEventsManager = ( }) } - isSupportedSessionRequest(request) && handleSessionRequest(request) + if (!isSupportedSessionRequest(request)) return + handleSessionRequest(request) }, [handleSessionRequest, state.web3wallet], ) diff --git a/src/plugins/walletConnectToDapps/hooks/useWalletConnectState.ts b/src/plugins/walletConnectToDapps/hooks/useWalletConnectState.ts index 59115fe81e8..6c3db0bd578 100644 --- a/src/plugins/walletConnectToDapps/hooks/useWalletConnectState.ts +++ b/src/plugins/walletConnectToDapps/hooks/useWalletConnectState.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { + isBip122AccountParams, isEthSignParams, isSignRequest, isSignTypedRequest, @@ -10,6 +11,7 @@ import type { KnownSigningMethod, WalletConnectState } from '@/plugins/walletCon import { extractAllConnectedAccounts, getSignParamsMessage, + getWalletAccountFromBip122Params, getWalletAccountFromCosmosParams, getWalletAccountFromEthParams, getWalletAddressFromEthSignParams, @@ -38,7 +40,10 @@ export const useWalletConnectState = (state: WalletConnectState) => { return getWalletAddressFromEthSignParams(connectedAccounts, requestParams) if (requestParams && isTransactionParamsArray(requestParams)) return requestParams[0].from if (requestParams && 'signerAddress' in requestParams) return requestParams.signerAddress - else return undefined + if (requestParams && isBip122AccountParams(requestParams)) { + return requestParams.account + } + return undefined }, [connectedAccounts, requestParams]) const accountMetadataById = useAppSelector(selectPortfolioAccountMetadata) @@ -53,7 +58,9 @@ export const useWalletConnectState = (state: WalletConnectState) => { return getWalletAccountFromEthParams(connectedAccounts, requestParams, chainId) if (requestParams && 'signerAddress' in requestParams) return getWalletAccountFromCosmosParams(connectedAccounts, requestParams) - else return undefined + if (requestParams && isBip122AccountParams(requestParams)) + return getWalletAccountFromBip122Params(connectedAccounts, requestParams) + return undefined }, [connectedAccounts, requestParams, chainId]) const accountMetadata = accountId ? accountMetadataById[accountId] : undefined diff --git a/src/plugins/walletConnectToDapps/typeGuards.ts b/src/plugins/walletConnectToDapps/typeGuards.ts index a090dd3609c..07f03d985b9 100644 --- a/src/plugins/walletConnectToDapps/typeGuards.ts +++ b/src/plugins/walletConnectToDapps/typeGuards.ts @@ -10,6 +10,15 @@ import type { } from '@/plugins/walletConnectToDapps/types' import { EIP155_SigningMethod } from '@/plugins/walletConnectToDapps/types' +export const isBip122AccountParams = ( + params: RequestParams, +): params is RequestParams & { account: string } => + typeof params === 'object' && + params !== null && + !Array.isArray(params) && + 'account' in params && + typeof (params as { account: unknown }).account === 'string' + export const isTransactionParamsArray = ( transactions: RequestParams | undefined, ): transactions is TransactionParams[] => diff --git a/src/plugins/walletConnectToDapps/types.ts b/src/plugins/walletConnectToDapps/types.ts index 60261839faf..454016966be 100644 --- a/src/plugins/walletConnectToDapps/types.ts +++ b/src/plugins/walletConnectToDapps/types.ts @@ -29,7 +29,14 @@ export enum CosmosSigningMethod { COSMOS_SIGN_AMINO = 'cosmos_signAmino', } -export type KnownSigningMethod = EIP155_SigningMethod | CosmosSigningMethod +export enum BIP122SigningMethod { + BIP122_SEND_TRANSFER = 'sendTransfer', + BIP122_SIGN_PSBT = 'signPsbt', + BIP122_SIGN_MESSAGE = 'signMessage', + BIP122_GET_ACCOUNT_ADDRESSES = 'getAccountAddresses', +} + +export type KnownSigningMethod = EIP155_SigningMethod | CosmosSigningMethod | BIP122SigningMethod export interface ModalData { proposal?: WalletKitTypes.EventArguments['session_proposal'] @@ -106,6 +113,7 @@ export enum WalletConnectModal { SignEIP155TransactionConfirmation = 'signEIP155TransactionConfirmation', SendEIP155TransactionConfirmation = 'sendEIP155TransactionConfirmation', SendCosmosTransactionConfirmation = 'sendCosmosTransactionConfirmation', + SendBitcoinTransactionConfirmation = 'sendBitcoinTransactionConfirmation', NoAccountsForChain = 'noAccountsForChain', } @@ -230,6 +238,56 @@ export type CosmosSignAminoCallRequest = { params: CosmosSignAminoCallRequestParams } +export type BIP122SendTransferCallRequestParams = { + account: string + recipientAddress: string + amount: string + memo?: string +} + +export type BIP122SendTransferCallRequest = { + method: BIP122SigningMethod.BIP122_SEND_TRANSFER + params: BIP122SendTransferCallRequestParams +} + +export type BIP122SignPsbtSignInput = { + address: string + index: number + sighashTypes?: number[] +} + +export type BIP122SignPsbtCallRequestParams = { + account: string + psbt: string + signInputs: BIP122SignPsbtSignInput[] + broadcast?: boolean +} + +export type BIP122SignPsbtCallRequest = { + method: BIP122SigningMethod.BIP122_SIGN_PSBT + params: BIP122SignPsbtCallRequestParams +} + +export type BIP122SignMessageCallRequestParams = { + account: string + message: string + protocol?: 'ecdsa' | 'bip322-simple' +} + +export type BIP122SignMessageCallRequest = { + method: BIP122SigningMethod.BIP122_SIGN_MESSAGE + params: BIP122SignMessageCallRequestParams +} + +export type BIP122GetAccountAddressesCallRequestParams = { + account: string +} + +export type BIP122GetAccountAddressesCallRequest = { + method: BIP122SigningMethod.BIP122_GET_ACCOUNT_ADDRESSES + params: BIP122GetAccountAddressesCallRequestParams +} + type EthSignTypedDataCallRequestParams = [account: string, message: string] export type EthSignTypedDataCallRequest = { method: @@ -250,6 +308,10 @@ export type WalletConnectRequest = | CosmosGetAccountsCallRequest | CosmosSignDirectCallRequest | CosmosSignAminoCallRequest + | BIP122SendTransferCallRequest + | BIP122SignPsbtCallRequest + | BIP122SignMessageCallRequest + | BIP122GetAccountAddressesCallRequest export type EthSignParams = | EthSignCallRequest @@ -263,6 +325,10 @@ export type RequestParams = | EthSignParams | CosmosSignDirectCallRequestParams | CosmosSignAminoCallRequestParams + | BIP122SendTransferCallRequestParams + | BIP122SignPsbtCallRequestParams + | BIP122SignMessageCallRequestParams + | BIP122GetAccountAddressesCallRequestParams export type ConfirmData = { nonce?: string diff --git a/src/plugins/walletConnectToDapps/utils.ts b/src/plugins/walletConnectToDapps/utils.ts index a7ad3b684e9..9180ae6ddba 100644 --- a/src/plugins/walletConnectToDapps/utils.ts +++ b/src/plugins/walletConnectToDapps/utils.ts @@ -128,6 +128,16 @@ export const getWalletAccountFromCosmosParams = ( ) } +export const getWalletAccountFromBip122Params = ( + accountIds: AccountId[], + params: { account: string }, +): AccountId => { + const paramsAccount = params.account + return ( + accountIds.find(accountId => paramsAccount?.includes(fromAccountId(accountId).account)) || '' + ) +} + /** * Get our address from params checking if params string contains one * of our wallet addresses diff --git a/src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.test.ts b/src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.test.ts new file mode 100644 index 00000000000..521a9c4031a --- /dev/null +++ b/src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.test.ts @@ -0,0 +1,406 @@ +import { formatJsonRpcResult } from '@json-rpc-tools/utils' +import { Psbt, Transaction } from '@shapeshiftoss/bitcoinjs-lib' +import type { HDWallet } from '@shapeshiftoss/hdwallet-core' +import { BTCInputScriptType } from '@shapeshiftoss/hdwallet-core' +import { describe, expect, it, vi } from 'vitest' + +import { approveBIP122Request } from './BIP122RequestHandlerUtil' + +import type { SupportedSessionRequest } from '@/plugins/walletConnectToDapps/types' +import { BIP122SigningMethod } from '@/plugins/walletConnectToDapps/types' + +const MOCK_PSBT_BASE64 = + 'cHNidP8BAOwCAAAABF4m1+zqCpnawzA+PwzGu10n5TwhL7UhpoIvtSEraqJ0AQAAAAD/////3t9AHspDWEuzAUh5e4jBi9Co9drUPpd0okJXGSsloCkBAAAAAP////9eJtfs6gqZ2sMwPj8MxrtdJ+U8IS+1IaaCL7UhK2qidAAAAAAA/////7kNS/toNI2/z8SVwQG12kktpRu7lGUv3AWkUPP3W5bYAAAAAAD/////At0NAAAAAAAAFgAUMKa3d370Y7JejBkFRoVhAjxWvtPpAwAAAAAAABYAFDCmt3d+9GOyXowZBUaFYQI8Vr7TAAAAAAABAR+ZCgAAAAAAABYAFDCmt3d+9GOyXowZBUaFYQI8Vr7TAAEBH5sKAAAAAAAAFgAUMKa3d370Y7JejBkFRoVhAjxWvtMAAQEfvwUAAAAAAAAWABQwprd3fvRjsl6MGQVGhWECPFa+0wABAR/pAwAAAAAAABYAFDCmt3d+9GOyXowZBUaFYQI8Vr7TAAAA' + +const MOCK_SIGNATURE = Buffer.from( + '3045022100abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890022012345678901234567890123456789012345678901234567890123456789012340' + + '1', + 'hex', +) +const MOCK_PUBKEY = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex', +) + +const createMockWallet = (overrides?: Record) => + ({ + _supportsBTC: true, + btcSupportsCoin: vi.fn().mockReturnValue(true), + btcSupportsScriptType: vi.fn().mockReturnValue(true), + btcGetAddress: vi.fn(), + btcSignTx: vi.fn(), + btcSignMessage: vi.fn(), + btcVerifyMessage: vi.fn(), + btcSupportsNativeShapeShift: vi.fn().mockReturnValue(false), + btcSupportsSecureTransfer: vi.fn().mockReturnValue(false), + btcNextAccountPath: vi.fn(), + ...overrides, + }) as unknown as HDWallet + +const createSignPsbtRequestEvent = ( + psbt: string, + signInputs: { address: string; index: number; sighashTypes?: number[] }[], + broadcast = false, +): SupportedSessionRequest => ({ + id: 1234, + topic: 'test-topic', + params: { + chainId: 'bip122:000000000019d6689c085ae165831e93', + request: { + method: BIP122SigningMethod.BIP122_SIGN_PSBT, + params: { + account: signInputs[0]?.address ?? 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + psbt, + signInputs, + broadcast, + }, + }, + }, + verifyContext: {} as any, +}) + +const createSignMessageRequestEvent = ( + message: string, + account = 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', +): SupportedSessionRequest => ({ + id: 5678, + topic: 'test-topic', + params: { + chainId: 'bip122:000000000019d6689c085ae165831e93', + request: { + method: BIP122SigningMethod.BIP122_SIGN_MESSAGE, + params: { + account, + message, + }, + }, + }, + verifyContext: {} as any, +}) + +describe('BIP122RequestHandlerUtil', () => { + describe('signMessage', () => { + it('should sign a message with native SegWit params for bc1q address', async () => { + const wallet = createMockWallet({ + btcSignMessage: vi.fn().mockResolvedValue({ + address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + signature: 'H+mockSignature==', + }), + }) + + const result = await approveBIP122Request({ + requestEvent: createSignMessageRequestEvent('Hello Bitcoin'), + wallet, + }) + + expect((wallet as any).btcSignMessage).toHaveBeenCalledWith({ + addressNList: [0x80000000 + 84, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + coin: 'Bitcoin', + scriptType: BTCInputScriptType.SpendWitness, + message: 'Hello Bitcoin', + }) + + expect(result).toEqual( + formatJsonRpcResult(5678, { + address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + signature: 'H+mockSignature==', + }), + ) + }) + + it('should use SpendP2SHWitness and BIP49 path for P2SH address', async () => { + const wallet = createMockWallet({ + btcSignMessage: vi.fn().mockResolvedValue({ + address: '3LUs8kTZo2G3X5wxv2sWKQJiC9btU1FUae', + signature: 'H+p2shSignature==', + }), + }) + + await approveBIP122Request({ + requestEvent: createSignMessageRequestEvent('Hello', '3LUs8kTZo2G3X5wxv2sWKQJiC9btU1FUae'), + wallet, + }) + + expect((wallet as any).btcSignMessage).toHaveBeenCalledWith({ + addressNList: [0x80000000 + 49, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + coin: 'Bitcoin', + scriptType: BTCInputScriptType.SpendP2SHWitness, + message: 'Hello', + }) + }) + + it('should use SpendAddress and BIP44 path for legacy P2PKH address', async () => { + const wallet = createMockWallet({ + btcSignMessage: vi.fn().mockResolvedValue({ + address: '1JBYZbazQAh9z59jnc7fvFSj2sTzKvVsgr', + signature: 'H+legacySignature==', + }), + }) + + await approveBIP122Request({ + requestEvent: createSignMessageRequestEvent('Hello', '1JBYZbazQAh9z59jnc7fvFSj2sTzKvVsgr'), + wallet, + }) + + expect((wallet as any).btcSignMessage).toHaveBeenCalledWith({ + addressNList: [0x80000000 + 44, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + coin: 'Bitcoin', + scriptType: BTCInputScriptType.SpendAddress, + message: 'Hello', + }) + }) + + it('should throw if btcSignMessage returns null', async () => { + const wallet = createMockWallet({ + btcSignMessage: vi.fn().mockResolvedValue(null), + }) + + await expect( + approveBIP122Request({ + requestEvent: createSignMessageRequestEvent('Hello'), + wallet, + }), + ).rejects.toThrow('Failed to sign Bitcoin message') + }) + }) + + describe('signPsbt', () => { + it('should return a finalized PSBT with finalScriptWitness on signed inputs', async () => { + const originalPsbt = Psbt.fromBase64(MOCK_PSBT_BASE64) + const inputCount = originalPsbt.txInputs.length + + const fakeTx = new Transaction() + fakeTx.version = originalPsbt.version + fakeTx.locktime = originalPsbt.locktime + for (const txInput of originalPsbt.txInputs) { + fakeTx.addInput(txInput.hash, txInput.index, txInput.sequence) + } + for (const txOutput of originalPsbt.txOutputs) { + fakeTx.addOutput(txOutput.script, txOutput.value) + } + for (let i = 0; i < inputCount; i++) { + fakeTx.setWitness(i, [MOCK_SIGNATURE, MOCK_PUBKEY]) + } + + const wallet = createMockWallet({ + btcSignTx: vi.fn().mockResolvedValue({ + serializedTx: fakeTx.toHex(), + signatures: [], + }), + }) + + const signInputs = Array.from({ length: inputCount }, (_, i) => ({ + address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + index: i, + sighashTypes: [1], + })) + + const result = await approveBIP122Request({ + requestEvent: createSignPsbtRequestEvent(MOCK_PSBT_BASE64, signInputs), + wallet, + }) + + const resultPsbt = (result as { result: { psbt: string } }).result.psbt + expect(resultPsbt).toBeDefined() + + const signedPsbt = Psbt.fromBase64(resultPsbt) + expect(signedPsbt).toBeDefined() + + for (let i = 0; i < inputCount; i++) { + expect(signedPsbt.data.inputs[i].finalScriptWitness).toBeDefined() + expect(signedPsbt.data.inputs[i].partialSig).toBeUndefined() + } + }) + + it('should only finalize inputs specified in signInputs', async () => { + const originalPsbt = Psbt.fromBase64(MOCK_PSBT_BASE64) + + const fakeTx = new Transaction() + fakeTx.version = originalPsbt.version + fakeTx.locktime = originalPsbt.locktime + for (const txInput of originalPsbt.txInputs) { + fakeTx.addInput(txInput.hash, txInput.index, txInput.sequence) + } + for (const txOutput of originalPsbt.txOutputs) { + fakeTx.addOutput(txOutput.script, txOutput.value) + } + for (let i = 0; i < originalPsbt.txInputs.length; i++) { + fakeTx.setWitness(i, [MOCK_SIGNATURE, MOCK_PUBKEY]) + } + + const wallet = createMockWallet({ + btcSignTx: vi.fn().mockResolvedValue({ + serializedTx: fakeTx.toHex(), + signatures: [], + }), + }) + + const signInputs = [ + { address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', index: 0, sighashTypes: [1] }, + { address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', index: 2, sighashTypes: [1] }, + ] + + const result = await approveBIP122Request({ + requestEvent: createSignPsbtRequestEvent(MOCK_PSBT_BASE64, signInputs), + wallet, + }) + + const resultPsbt = (result as { result: { psbt: string } }).result.psbt + const signedPsbt = Psbt.fromBase64(resultPsbt) + + expect(signedPsbt.data.inputs[0].finalScriptWitness).toBeDefined() + expect(signedPsbt.data.inputs[2].finalScriptWitness).toBeDefined() + + expect(signedPsbt.data.inputs[1].finalScriptWitness).toBeUndefined() + expect(signedPsbt.data.inputs[3].finalScriptWitness).toBeUndefined() + }) + + it('should broadcast and return txid when broadcast=true', async () => { + const originalPsbt = Psbt.fromBase64(MOCK_PSBT_BASE64) + + const fakeTx = new Transaction() + fakeTx.version = originalPsbt.version + fakeTx.locktime = originalPsbt.locktime + for (const txInput of originalPsbt.txInputs) { + fakeTx.addInput(txInput.hash, txInput.index, txInput.sequence) + } + for (const txOutput of originalPsbt.txOutputs) { + fakeTx.addOutput(txOutput.script, txOutput.value) + } + for (let i = 0; i < originalPsbt.txInputs.length; i++) { + fakeTx.setWitness(i, [MOCK_SIGNATURE, MOCK_PUBKEY]) + } + + const wallet = createMockWallet({ + btcSignTx: vi.fn().mockResolvedValue({ + serializedTx: fakeTx.toHex(), + signatures: [], + }), + }) + + const mockChainAdapter = { + broadcastTransaction: vi.fn().mockResolvedValue('mock-txid-123'), + } + + const signInputs = Array.from({ length: 4 }, (_, i) => ({ + address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + index: i, + sighashTypes: [1], + })) + + const result = await approveBIP122Request({ + requestEvent: createSignPsbtRequestEvent(MOCK_PSBT_BASE64, signInputs, true), + wallet, + chainAdapter: mockChainAdapter as any, + }) + + expect(mockChainAdapter.broadcastTransaction).toHaveBeenCalledWith({ + hex: fakeTx.toHex(), + }) + expect(result).toEqual(formatJsonRpcResult(1234, { txid: 'mock-txid-123' })) + }) + + it('should throw if btcSignTx returns null', async () => { + const wallet = createMockWallet({ + btcSignTx: vi.fn().mockResolvedValue(null), + }) + + const signInputs = [ + { address: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', index: 0, sighashTypes: [1] }, + ] + + await expect( + approveBIP122Request({ + requestEvent: createSignPsbtRequestEvent(MOCK_PSBT_BASE64, signInputs), + wallet, + }), + ).rejects.toThrow('Failed to sign Bitcoin transaction') + }) + }) + + describe('sendTransfer', () => { + it('should build, sign, broadcast, and return txid', async () => { + const wallet = createMockWallet() + + const mockChainAdapter = { + getPublicKey: vi.fn().mockResolvedValue({ xpub: 'xpub123' }), + getFeeData: vi.fn().mockResolvedValue({ + average: { chainSpecific: { satoshiPerByte: '10' } }, + }), + buildSendTransaction: vi.fn().mockResolvedValue({ + txToSign: { rawTx: 'mock-raw-tx' }, + }), + signTransaction: vi.fn().mockResolvedValue('signed-hex-123'), + broadcastTransaction: vi.fn().mockResolvedValue('broadcast-txid-456'), + } + + const requestEvent: SupportedSessionRequest = { + id: 9999, + topic: 'test-topic', + params: { + chainId: 'bip122:000000000019d6689c085ae165831e93', + request: { + method: BIP122SigningMethod.BIP122_SEND_TRANSFER, + params: { + account: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + recipientAddress: 'bc1qrecipient', + amount: '50000', + }, + }, + }, + verifyContext: {} as any, + } + + const result = await approveBIP122Request({ + requestEvent, + wallet, + chainAdapter: mockChainAdapter as any, + }) + + expect(mockChainAdapter.getPublicKey).toHaveBeenCalled() + expect(mockChainAdapter.getFeeData).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'bc1qrecipient', + value: '50000', + }), + ) + expect(mockChainAdapter.buildSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'bc1qrecipient', + value: '50000', + }), + ) + expect(mockChainAdapter.signTransaction).toHaveBeenCalled() + expect(mockChainAdapter.broadcastTransaction).toHaveBeenCalledWith({ hex: 'signed-hex-123' }) + expect(result).toEqual(formatJsonRpcResult(9999, { txid: 'broadcast-txid-456' })) + }) + + it('should throw if chainAdapter is not provided', async () => { + const wallet = createMockWallet() + + const requestEvent: SupportedSessionRequest = { + id: 9999, + topic: 'test-topic', + params: { + chainId: 'bip122:000000000019d6689c085ae165831e93', + request: { + method: BIP122SigningMethod.BIP122_SEND_TRANSFER, + params: { + account: 'bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt', + recipientAddress: 'bc1qrecipient', + amount: '50000', + }, + }, + }, + verifyContext: {} as any, + } + + await expect( + approveBIP122Request({ + requestEvent, + wallet, + }), + ).rejects.toThrow('Chain adapter required for sendTransfer') + }) + }) +}) diff --git a/src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.ts b/src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.ts new file mode 100644 index 00000000000..de8c4597ed9 --- /dev/null +++ b/src/plugins/walletConnectToDapps/utils/BIP122RequestHandlerUtil.ts @@ -0,0 +1,315 @@ +import type { JsonRpcResult } from '@json-rpc-tools/utils' +import { formatJsonRpcResult } from '@json-rpc-tools/utils' +import { address as btcAddress, Psbt, Transaction } from '@shapeshiftoss/bitcoinjs-lib' +import type { UtxoChainAdapter } from '@shapeshiftoss/chain-adapters' +import type { HDWallet } from '@shapeshiftoss/hdwallet-core' +import { BTCInputScriptType, BTCOutputAddressType, supportsBTC } from '@shapeshiftoss/hdwallet-core' +import { UtxoAccountType } from '@shapeshiftoss/types' +import { getSdkError } from '@walletconnect/utils' + +import type { + BIP122SendTransferCallRequestParams, + BIP122SignMessageCallRequestParams, + BIP122SignPsbtCallRequestParams, + SupportedSessionRequest, +} from '@/plugins/walletConnectToDapps/types' +import { BIP122SigningMethod } from '@/plugins/walletConnectToDapps/types' + +type ApproveBIP122RequestArgs = { + requestEvent: SupportedSessionRequest + wallet: HDWallet + chainAdapter?: UtxoChainAdapter +} + +type BtcScriptInfo = { + scriptType: BTCInputScriptType + addressNList: number[] +} + +const detectBtcScriptType = (address: string): BtcScriptInfo => { + if (address.startsWith('bc1q') || address.startsWith('tb1q')) { + return { + scriptType: BTCInputScriptType.SpendWitness, + addressNList: [0x80000000 + 84, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + } + } + if (address.startsWith('bc1p') || address.startsWith('tb1p')) { + return { + scriptType: BTCInputScriptType.SpendWitness, + addressNList: [0x80000000 + 86, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + } + } + if (address.startsWith('3') || address.startsWith('2')) { + return { + scriptType: BTCInputScriptType.SpendP2SHWitness, + addressNList: [0x80000000 + 49, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + } + } + return { + scriptType: BTCInputScriptType.SpendAddress, + addressNList: [0x80000000 + 44, 0x80000000 + 0, 0x80000000 + 0, 0, 0], + } +} + +const detectScriptTypeFromScript = (script: Buffer | Uint8Array): BTCInputScriptType => { + if (script.length === 22 && script[0] === 0x00 && script[1] === 0x14) + return BTCInputScriptType.SpendWitness + if (script.length === 23 && script[0] === 0xa9 && script[22] === 0x87) + return BTCInputScriptType.SpendP2SHWitness + if (script.length === 34 && script[0] === 0x51 && script[1] === 0x20) + return BTCInputScriptType.SpendWitness + if (script.length === 25 && script[0] === 0x76 && script[1] === 0xa9) + return BTCInputScriptType.SpendAddress + return BTCInputScriptType.SpendWitness +} + +const addressNListForScriptType = (scriptType: BTCInputScriptType): number[] => { + switch (scriptType) { + case BTCInputScriptType.SpendP2SHWitness: + return [0x80000000 + 49, 0x80000000 + 0, 0x80000000 + 0, 0, 0] + case BTCInputScriptType.SpendAddress: + return [0x80000000 + 44, 0x80000000 + 0, 0x80000000 + 0, 0, 0] + case BTCInputScriptType.SpendWitness: + default: + return [0x80000000 + 84, 0x80000000 + 0, 0x80000000 + 0, 0, 0] + } +} + +const serializeWitnessStack = (witness: (Buffer | Uint8Array)[]): Buffer => { + let length = 1 + for (const item of witness) { + length += 1 + item.length + } + const result = Buffer.alloc(length) + let offset = 0 + result.writeUInt8(witness.length, offset++) + for (const item of witness) { + result.writeUInt8(item.length, offset++) + result.set(item, offset) + offset += item.length + } + return result +} + +export const approveBIP122Request = async ({ + requestEvent, + wallet, + chainAdapter, +}: ApproveBIP122RequestArgs): Promise> => { + const { params, id } = requestEvent + const { request } = params + + if (!supportsBTC(wallet)) { + throw new Error('Wallet does not support Bitcoin') + } + + switch (request.method) { + case BIP122SigningMethod.BIP122_SIGN_MESSAGE: { + const { account, message } = request.params as BIP122SignMessageCallRequestParams + const { scriptType, addressNList } = detectBtcScriptType(account) + + const signedMessage = await wallet.btcSignMessage({ + addressNList, + coin: 'Bitcoin', + scriptType, + message, + }) + + if (!signedMessage) { + throw new Error('Failed to sign Bitcoin message') + } + + return formatJsonRpcResult(id, { + address: signedMessage.address, + signature: signedMessage.signature, + }) + } + + case BIP122SigningMethod.BIP122_SIGN_PSBT: { + const { + psbt: psbtBase64, + signInputs, + broadcast, + } = request.params as BIP122SignPsbtCallRequestParams + + const psbt = Psbt.fromBase64(psbtBase64) + const txInputs = psbt.txInputs + const txOutputs = psbt.txOutputs + + const signInputIndices = new Set(signInputs.map(si => si.index)) + const signInputAddressMap = new Map(signInputs.map(si => [si.index, si.address])) + + const inputs = txInputs.map((txInput, i) => { + const psbtInput = psbt.data.inputs[i] + const witnessUtxo = psbtInput.witnessUtxo + const nonWitnessUtxo = psbtInput.nonWitnessUtxo + + const txid = Buffer.from(txInput.hash).reverse().toString('hex') + const vout = txInput.index + const sequence = txInput.sequence + + const signInputAddr = signInputAddressMap.get(i) + const { scriptType, addressNList } = (() => { + if (signInputAddr) return detectBtcScriptType(signInputAddr) + if (witnessUtxo) { + try { + const addr = btcAddress.fromOutputScript(witnessUtxo.script) + return detectBtcScriptType(addr) + } catch { + const st = detectScriptTypeFromScript(witnessUtxo.script) + return { scriptType: st, addressNList: addressNListForScriptType(st) } + } + } + if (nonWitnessUtxo) { + try { + const prevTx = Transaction.fromBuffer(Buffer.from(nonWitnessUtxo)) + const prevOutput = prevTx.outs[vout] + if (prevOutput) { + const addr = btcAddress.fromOutputScript(prevOutput.script) + return detectBtcScriptType(addr) + } + } catch { + // fall through + } + } + return { + scriptType: BTCInputScriptType.SpendWitness, + addressNList: addressNListForScriptType(BTCInputScriptType.SpendWitness), + } + })() + + if (nonWitnessUtxo) { + return { + addressNList, + scriptType, + amount: witnessUtxo ? witnessUtxo.value.toString() : '0', + vout, + txid, + hex: Buffer.from(nonWitnessUtxo).toString('hex'), + ...(sequence !== undefined && { sequence }), + } + } + + if (witnessUtxo) { + const scriptPubKeyHex = Buffer.from(witnessUtxo.script).toString('hex') + const fakeTx = { + version: 0, + locktime: 0, + vin: [] as [], + vout: Array.from({ length: vout + 1 }, (_, j) => ({ + value: j === vout ? witnessUtxo.value.toString() : '0', + scriptPubKey: { + hex: j === vout ? scriptPubKeyHex : '', + }, + })), + } + + return { + addressNList, + scriptType, + amount: witnessUtxo.value.toString(), + vout, + txid, + tx: fakeTx, + ...(sequence !== undefined && { sequence }), + } + } + + throw new Error(`PSBT input ${i} has neither witnessUtxo nor nonWitnessUtxo`) + }) + + const outputs = txOutputs.map(txOutput => { + if (txOutput.address) { + return { + addressType: BTCOutputAddressType.Spend, + amount: txOutput.value.toString(), + address: txOutput.address, + } + } + + return { + amount: '0' as const, + opReturnData: Buffer.from(txOutput.script) as Uint8Array, + } + }) + + const signedTx = await wallet.btcSignTx({ + coin: 'Bitcoin', + inputs: inputs as any, + outputs: outputs as any, + version: psbt.version, + locktime: psbt.locktime, + }) + + if (!signedTx) throw new Error('Failed to sign Bitcoin transaction') + + if (broadcast && chainAdapter) { + const txid = await chainAdapter.broadcastTransaction({ + hex: signedTx.serializedTx, + }) + return formatJsonRpcResult(id, { txid }) + } + + const tx = Transaction.fromHex(signedTx.serializedTx) + for (const i of signInputIndices) { + const { script, witness } = tx.ins[i] + const update: { finalScriptSig?: Buffer; finalScriptWitness?: Buffer } = {} + + if (script.length > 0) { + update.finalScriptSig = Buffer.from(script) + } + + if (witness.length >= 2) { + update.finalScriptWitness = serializeWitnessStack(witness) + } + + if (update.finalScriptSig || update.finalScriptWitness) { + psbt.updateInput(i, update) + } + } + + return formatJsonRpcResult(id, { psbt: psbt.toBase64() }) + } + + case BIP122SigningMethod.BIP122_SEND_TRANSFER: { + const { account, recipientAddress, amount } = + request.params as BIP122SendTransferCallRequestParams + + if (!chainAdapter) throw new Error('Chain adapter required for sendTransfer') + + const accountType = (() => { + if (account.startsWith('3') || account.startsWith('2')) return UtxoAccountType.SegwitP2sh + if (account.startsWith('1') || account.startsWith('m') || account.startsWith('n')) + return UtxoAccountType.P2pkh + return UtxoAccountType.SegwitNative + })() + + const pubkey = (await chainAdapter.getPublicKey(wallet, 0, accountType)).xpub + + const feeData = await chainAdapter.getFeeData({ + to: recipientAddress, + value: amount, + chainSpecific: { pubkey }, + }) + + const { txToSign } = await chainAdapter.buildSendTransaction({ + to: recipientAddress, + value: amount, + wallet, + accountNumber: 0, + chainSpecific: { + accountType, + satoshiPerByte: feeData.average.chainSpecific.satoshiPerByte, + }, + }) + + const signedHex = await chainAdapter.signTransaction({ txToSign, wallet }) + const txid = await chainAdapter.broadcastTransaction({ hex: signedHex }) + + return formatJsonRpcResult(id, { txid }) + } + + default: + throw new Error(getSdkError('INVALID_METHOD').message) + } +} diff --git a/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts b/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts index dcecf72f7e3..6943be2babc 100644 --- a/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts +++ b/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts @@ -1,24 +1,120 @@ +import { payments } from '@shapeshiftoss/bitcoinjs-lib' import type { AccountId, ChainId } from '@shapeshiftoss/caip' -import { CHAIN_NAMESPACE, fromAccountId } from '@shapeshiftoss/caip' +import { btcChainId, CHAIN_NAMESPACE, fromAccountId, toAccountId } from '@shapeshiftoss/caip' import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { ProposalTypes, SessionTypes } from '@walletconnect/types' +import * as bip32 from 'bip32' import { uniq } from 'lodash' -import { CosmosSigningMethod, EIP155_SigningMethod } from '@/plugins/walletConnectToDapps/types' +import { + BIP122SigningMethod, + CosmosSigningMethod, + EIP155_SigningMethod, +} from '@/plugins/walletConnectToDapps/types' const DEFAULT_EIP155_METHODS = Object.values(EIP155_SigningMethod).filter( method => method !== EIP155_SigningMethod.GET_CAPABILITIES, ) -const DEFAULT_COSMOS_METHODS = Object.values(CosmosSigningMethod) +const DEFAULT_BIP122_METHODS = Object.values(BIP122SigningMethod) +const DEFAULT_BIP122_EVENTS: string[] = [] +const DEFAULT_COSMOS_METHODS = Object.values(CosmosSigningMethod) const DEFAULT_COSMOS_EVENTS: string[] = [] +const isBip122ChainId = (chainId: string): boolean => chainId === btcChainId + const isCosmosSdkChainId = (chainId: string): boolean => chainId.startsWith(`${CHAIN_NAMESPACE.CosmosSdk}:`) export const isWcSupportedChainId = (chainId: string): boolean => - isEvmChainId(chainId) || isCosmosSdkChainId(chainId) + isEvmChainId(chainId) || isBip122ChainId(chainId) || isCosmosSdkChainId(chainId) + +const ZPUB_NETWORK = { + bip32: { public: 0x04b24746, private: 0x04b2430c }, + messagePrefix: '\x18Bitcoin Signed Message:\n', + bech32: 'bc', + pubKeyHash: 0x00, + scriptHash: 0x05, + wif: 0x80, +} + +const YPUB_NETWORK = { + bip32: { public: 0x049d7cb2, private: 0x049d7878 }, + messagePrefix: '\x18Bitcoin Signed Message:\n', + bech32: 'bc', + pubKeyHash: 0x00, + scriptHash: 0x05, + wif: 0x80, +} + +const XPUB_NETWORK = { + bip32: { public: 0x0488b21e, private: 0x0488ade4 }, + messagePrefix: '\x18Bitcoin Signed Message:\n', + bech32: 'bc', + pubKeyHash: 0x00, + scriptHash: 0x05, + wif: 0x80, +} + +export const isExtPubKey = (account: string): boolean => + ['xpub', 'ypub', 'zpub', 'tpub', 'upub', 'vpub', 'Ypub', 'Zpub', 'dgub', 'Mtub', 'Ltub'].some( + prefix => account.startsWith(prefix), + ) + +export const deriveAddressFromExtPubKey = (extPubKey: string): string => { + const isNativeSegwit = + extPubKey.startsWith('zpub') || extPubKey.startsWith('vpub') || extPubKey.startsWith('Zpub') + const isNestedSegwit = extPubKey.startsWith('ypub') || extPubKey.startsWith('Ypub') + + const network = (() => { + if (isNativeSegwit) return ZPUB_NETWORK + if (isNestedSegwit) return YPUB_NETWORK + return XPUB_NETWORK + })() + + const node = bip32.fromBase58(extPubKey, network) + const child = node.derive(0).derive(0) + + if (isNativeSegwit) { + const { address } = payments.p2wpkh({ pubkey: child.publicKey, network }) + if (!address) throw new Error('Failed to derive P2WPKH address') + return address + } + + if (isNestedSegwit) { + const { address } = payments.p2sh({ + redeem: payments.p2wpkh({ pubkey: child.publicKey, network }), + network, + }) + if (!address) throw new Error('Failed to derive P2SH-P2WPKH address') + return address + } + + const { address } = payments.p2pkh({ pubkey: child.publicKey, network }) + if (!address) throw new Error('Failed to derive P2PKH address') + return address +} + +const utxoAccountIdToWcAccount = (accountId: AccountId): string => { + const { chainId, account } = fromAccountId(accountId) + if (chainId !== btcChainId || !isExtPubKey(account)) return accountId + const address = deriveAddressFromExtPubKey(account) + return toAccountId({ chainId, account: address }) +} + +const getDefaultMethods = (key: string): string[] => { + switch (key) { + case CHAIN_NAMESPACE.Evm: + return DEFAULT_EIP155_METHODS + case CHAIN_NAMESPACE.Utxo: + return DEFAULT_BIP122_METHODS + case CHAIN_NAMESPACE.CosmosSdk: + return DEFAULT_COSMOS_METHODS + default: + return [] + } +} export const createApprovalNamespaces = ( requiredNamespaces: ProposalTypes.RequiredNamespaces, @@ -28,17 +124,6 @@ export const createApprovalNamespaces = ( ): SessionTypes.Namespaces => { const approvedNamespaces: SessionTypes.Namespaces = {} - const getDefaultMethods = (key: string): string[] => { - switch (key) { - case CHAIN_NAMESPACE.Evm: - return DEFAULT_EIP155_METHODS - case CHAIN_NAMESPACE.CosmosSdk: - return DEFAULT_COSMOS_METHODS - default: - return [] - } - } - const createNamespaceEntry = ( key: string, proposalNamespace: ProposalTypes.RequiredNamespace, @@ -55,10 +140,14 @@ export const createApprovalNamespaces = ( } Object.entries(requiredNamespaces).forEach(([key, proposalNamespace]) => { - const selectedAccountsForKey = selectedAccountIds.filter(accountId => { - const { chainNamespace } = fromAccountId(accountId) - return chainNamespace === key - }) + const selectedAccountsForKey = selectedAccountIds + .filter(accountId => { + const { chainNamespace } = fromAccountId(accountId) + return chainNamespace === key + }) + .map(accountId => + key === CHAIN_NAMESPACE.Utxo ? utxoAccountIdToWcAccount(accountId) : accountId, + ) if (selectedAccountsForKey.length > 0) { approvedNamespaces[key] = createNamespaceEntry(key, proposalNamespace, selectedAccountsForKey) @@ -69,7 +158,6 @@ export const createApprovalNamespaces = ( namespace => namespace.chains ?? [], ) - // Handle optional EVM namespaces const additionalEvmChainIds = selectedChainIds.filter( chainId => isEvmChainId(chainId) && !requiredChainIds.includes(chainId), ) @@ -77,7 +165,7 @@ export const createApprovalNamespaces = ( if (additionalEvmChainIds.length > 0) { const eip155AccountIds = selectedAccountIds.filter( accountId => - fromAccountId(accountId).chainNamespace === 'eip155' && + fromAccountId(accountId).chainNamespace === CHAIN_NAMESPACE.Evm && additionalEvmChainIds.includes(fromAccountId(accountId).chainId), ) @@ -97,7 +185,38 @@ export const createApprovalNamespaces = ( } } - // Handle optional Cosmos namespaces + const additionalBip122ChainIds = selectedChainIds.filter( + chainId => isBip122ChainId(chainId) && !requiredChainIds.includes(chainId), + ) + + if (additionalBip122ChainIds.length > 0) { + const bip122AccountIds = selectedAccountIds + .filter( + accountId => + fromAccountId(accountId).chainNamespace === CHAIN_NAMESPACE.Utxo && + additionalBip122ChainIds.includes(fromAccountId(accountId).chainId), + ) + .map(utxoAccountIdToWcAccount) + + if (bip122AccountIds.length > 0) { + const existing = approvedNamespaces.bip122 + approvedNamespaces.bip122 = { + ...(existing ?? {}), + accounts: uniq([...(existing?.accounts ?? []), ...bip122AccountIds]), + methods: uniq([ + ...(existing?.methods ?? DEFAULT_BIP122_METHODS), + ...(optionalNamespaces?.bip122?.methods && optionalNamespaces.bip122.methods.length > 0 + ? optionalNamespaces.bip122.methods + : DEFAULT_BIP122_METHODS), + ]), + events: uniq([ + ...(existing?.events ?? DEFAULT_BIP122_EVENTS), + ...(optionalNamespaces?.bip122?.events ?? []), + ]), + } + } + } + const cosmosNamespaceKey = CHAIN_NAMESPACE.CosmosSdk const additionalCosmosChainIds = selectedChainIds.filter( chainId => isCosmosSdkChainId(chainId) && !requiredChainIds.includes(chainId), diff --git a/src/plugins/walletConnectToDapps/utils/parsePsbt.test.ts b/src/plugins/walletConnectToDapps/utils/parsePsbt.test.ts new file mode 100644 index 00000000000..0cc2d7065dc --- /dev/null +++ b/src/plugins/walletConnectToDapps/utils/parsePsbt.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import type { ParsedPsbt } from './parsePsbt' +import { parsePsbt } from './parsePsbt' + +const MOCK_PSBT_BASE64 = + 'cHNidP8BAOwCAAAABF4m1+zqCpnawzA+PwzGu10n5TwhL7UhpoIvtSEraqJ0AQAAAAD/////3t9AHspDWEuzAUh5e4jBi9Co9drUPpd0okJXGSsloCkBAAAAAP////9eJtfs6gqZ2sMwPj8MxrtdJ+U8IS+1IaaCL7UhK2qidAAAAAAA/////7kNS/toNI2/z8SVwQG12kktpRu7lGUv3AWkUPP3W5bYAAAAAAD/////At0NAAAAAAAAFgAUMKa3d370Y7JejBkFRoVhAjxWvtPpAwAAAAAAABYAFDCmt3d+9GOyXowZBUaFYQI8Vr7TAAAAAAABAR+ZCgAAAAAAABYAFDCmt3d+9GOyXowZBUaFYQI8Vr7TAAEBH5sKAAAAAAAAFgAUMKa3d370Y7JejBkFRoVhAjxWvtMAAQEfvwUAAAAAAAAWABQwprd3fvRjsl6MGQVGhWECPFa+0wABAR/pAwAAAAAAABYAFDCmt3d+9GOyXowZBUaFYQI8Vr7TAAAA' + +const assertParsed = (result: ParsedPsbt | null): ParsedPsbt => { + expect(result).not.toBeNull() + return result as ParsedPsbt +} + +describe('parsePsbt', () => { + it('should parse a valid PSBT and return inputs/outputs', () => { + const result = assertParsed(parsePsbt(MOCK_PSBT_BASE64)) + + expect(result.inputs).toHaveLength(4) + expect(result.outputs).toHaveLength(2) + }) + + it('should derive addresses from witnessUtxo scripts', () => { + const result = assertParsed(parsePsbt(MOCK_PSBT_BASE64)) + + for (const input of result.inputs) { + expect(input.address).toBe('bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt') + } + }) + + it('should extract correct values from witnessUtxo', () => { + const result = assertParsed(parsePsbt(MOCK_PSBT_BASE64)) + + expect(result.inputs[0].value).toBe('2713') + expect(result.inputs[1].value).toBe('2715') + expect(result.inputs[2].value).toBe('1471') + expect(result.inputs[3].value).toBe('1001') + }) + + it('should extract correct output values and addresses', () => { + const result = assertParsed(parsePsbt(MOCK_PSBT_BASE64)) + + expect(result.outputs[0].address).toBe('bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt') + expect(result.outputs[0].value).toBe('3549') + expect(result.outputs[1].address).toBe('bc1qxzntwam7733myh5vryz5dptpqg79d0kn80clmt') + expect(result.outputs[1].value).toBe('1001') + }) + + it('should extract txid and vout for each input', () => { + const result = assertParsed(parsePsbt(MOCK_PSBT_BASE64)) + + for (const input of result.inputs) { + expect(input.txid).toMatch(/^[0-9a-f]{64}$/) + expect(typeof input.vout).toBe('number') + } + }) + + it('should return null for invalid base64', () => { + expect(parsePsbt('not-valid-base64!!!')).toBeNull() + }) + + it('should return null for valid base64 that is not a PSBT', () => { + expect(parsePsbt('aGVsbG8gd29ybGQ=')).toBeNull() + }) + + it('should include version and locktime', () => { + const result = assertParsed(parsePsbt(MOCK_PSBT_BASE64)) + + expect(result.version).toBe(2) + expect(result.locktime).toBe(0) + }) +}) diff --git a/src/plugins/walletConnectToDapps/utils/parsePsbt.ts b/src/plugins/walletConnectToDapps/utils/parsePsbt.ts new file mode 100644 index 00000000000..b55f109a9a4 --- /dev/null +++ b/src/plugins/walletConnectToDapps/utils/parsePsbt.ts @@ -0,0 +1,57 @@ +import { address as btcAddress, Psbt } from '@shapeshiftoss/bitcoinjs-lib' + +export type ParsedInput = { + txid: string + vout: number + address: string | null + value: string +} + +export type ParsedOutput = { + address: string | null + value: string +} + +export type ParsedPsbt = { + inputs: ParsedInput[] + outputs: ParsedOutput[] + version: number + locktime: number +} + +export const parsePsbt = (psbtBase64: string): ParsedPsbt | null => { + try { + const psbt = Psbt.fromBase64(psbtBase64) + + const inputs: ParsedInput[] = psbt.txInputs.map((txInput, i) => { + const psbtInput = psbt.data.inputs[i] + const witnessUtxo = psbtInput.witnessUtxo + const txid = Buffer.from(txInput.hash).reverse().toString('hex') + + let inputAddress: string | null = null + if (witnessUtxo) { + try { + inputAddress = btcAddress.fromOutputScript(witnessUtxo.script) + } catch { + // non-standard script + } + } + + return { + txid, + vout: txInput.index, + address: inputAddress, + value: witnessUtxo ? witnessUtxo.value.toString() : '0', + } + }) + + const outputs: ParsedOutput[] = psbt.txOutputs.map(txOutput => ({ + address: txOutput.address ?? null, + value: txOutput.value.toString(), + })) + + return { inputs, outputs, version: psbt.version, locktime: psbt.locktime } + } catch { + return null + } +} From 7bf30cf69b3d4195c65444d01b4c759b9378f4ed Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Tue, 3 Mar 2026 13:17:45 +0100 Subject: [PATCH 06/31] feat: honor dev port override (#12076) --- vite.config.mts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vite.config.mts b/vite.config.mts index 01b37cfccec..6bc94ed9811 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -189,6 +189,7 @@ const serveCompressedAssets: PluginOption = { // eslint-disable-next-line import/no-default-export export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const port = Number(process.env.PORT) || 3000 return { plugins: [ @@ -245,7 +246,7 @@ export default defineConfig(({ mode }) => { ), }, server: { - port: 3000, + port, headers, host: '0.0.0.0', allowedHosts: true, @@ -268,7 +269,7 @@ export default defineConfig(({ mode }) => { }, }, preview: { - port: 3000, + port, headers, }, worker: { From 55b9823c16a373bf822846dcbdb25bda9e38d8f3 Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:11:43 -0700 Subject: [PATCH 07/31] chore: update rfox ipfs hash (#12090) --- src/pages/RFOX/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/RFOX/constants.ts b/src/pages/RFOX/constants.ts index 17ae977cc7b..c9fcfc12e15 100644 --- a/src/pages/RFOX/constants.ts +++ b/src/pages/RFOX/constants.ts @@ -11,7 +11,7 @@ export const unstakeEvent = getAbiItem({ abi: RFOX_ABI, name: 'Unstake' }) export const IPFS_GATEWAY = 'https://gateway.pinata.cloud/ipfs' -export const CURRENT_EPOCH_IPFS_HASH = 'bafkreifsyavfxkj73pk5xy2gzqd4yh6njj2d6i6yfadv7xxd4xtqrmx32u' +export const CURRENT_EPOCH_IPFS_HASH = 'bafkreibf7lcutzt65fxcgwtpsvey7pb4nv77btdctw6aukrhlqlp3gncc4' export const STUB_RUNE_ADDRESS = 'thor1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn8p0r8' export const RFOX_V3_UPGRADE_EPOCH = 18 From 7148c6b9452ea9e6c0f906c98ff976da910bad17 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 10:47:46 +0100 Subject: [PATCH 08/31] fix: chainflip lending information architecture revamp polish (#12091) --- .claude/skills/qabot/SKILL.md | 21 + e2e/fixtures/chainflip-lending-revamp-ui.yaml | 32 + src/assets/translations/en/main.json | 16 +- .../ChainflipLending/ChainflipLending.tsx | 4 +- src/pages/ChainflipLending/Pool/Pool.tsx | 684 +++++++++--------- .../Pool/components/Borrow/Borrow.tsx | 69 +- .../Pool/components/Borrow/BorrowInput.tsx | 55 +- .../Pool/components/Borrow/Collateral.tsx | 77 +- .../components/Borrow/CollateralInput.tsx | 61 +- .../Pool/components/Borrow/LtvGauge.tsx | 66 +- .../Pool/components/Borrow/Repay.tsx | 8 +- .../Pool/components/Borrow/RepayInput.tsx | 4 + .../Pool/components/Deposit/Deposit.tsx | 75 +- .../Pool/components/Deposit/DepositInput.tsx | 53 +- .../Pool/components/Egress/Egress.tsx | 69 +- .../Pool/components/Egress/EgressInput.tsx | 57 +- .../Pool/components/Supply/Supply.tsx | 77 +- .../Pool/components/Supply/SupplyInput.tsx | 55 +- .../Pool/components/Withdraw/Withdraw.tsx | 69 +- .../components/Withdraw/WithdrawInput.tsx | 62 +- .../components/ChainflipLendingHeader.tsx | 17 - .../ChainflipLending/components/Markets.tsx | 81 ++- .../components/MyBalances.tsx | 94 +-- .../hooks/useChainflipSafeModeStatuses.ts | 46 ++ 24 files changed, 1087 insertions(+), 765 deletions(-) create mode 100644 e2e/fixtures/chainflip-lending-revamp-ui.yaml create mode 100644 src/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses.ts diff --git a/.claude/skills/qabot/SKILL.md b/.claude/skills/qabot/SKILL.md index 0f39277af4e..36c8b00b570 100644 --- a/.claude/skills/qabot/SKILL.md +++ b/.claude/skills/qabot/SKILL.md @@ -90,6 +90,27 @@ On first visit to any origin (gome.shapeshift.com, release.shapeshift.com, etc.) **IMPORTANT**: Always use the `qabot` profile. The native wallet is stored in this profile's IndexedDB per-origin. +Use a shell-scoped command alias at session start to reduce command noise: + +```bash +AB='agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot' +``` + +Then use `$AB` for all commands in that shell session: + +```bash +$AB open +$AB snapshot +$AB click "Connect Wallet" +$AB screenshot /tmp/step-0.png +``` + +When you need a headed run, append `--headed` only for that command: + +```bash +$AB --headed open +``` + ```bash agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot open ``` diff --git a/e2e/fixtures/chainflip-lending-revamp-ui.yaml b/e2e/fixtures/chainflip-lending-revamp-ui.yaml new file mode 100644 index 00000000000..29409da8389 --- /dev/null +++ b/e2e/fixtures/chainflip-lending-revamp-ui.yaml @@ -0,0 +1,32 @@ +name: Chainflip Lending Revamp UI +description: Validates the revamped Chainflip lending surfaces for USDC pool and key action modals. +route: /chainflip-lending +steps: + - name: Chainflip lending dashboard + instruction: Open the chainflip lending dashboard and ensure the refreshed layout is visible. + expected: Chainflip lending dashboard cards and market tables are visible. + screenshot: true + - name: Open usdc pool + instruction: Navigate into the USDC pool from the All Markets table. + expected: USDC pool page is visible with action tabs. + screenshot: true + - name: Supply modal + instruction: Open Supply action and verify supply input controls. + expected: Supply modal is open with amount input, max and submit controls. + screenshot: true + - name: Deposit and egress modal + instruction: Open Deposit to Chainflip tab and open Withdraw modal. + expected: Egress modal is open with destination toggle and amount controls. + screenshot: true + - name: Collateral modal ltv gauge + instruction: Open Collateral tab and open Add Collateral modal. + expected: LTV gauge shows target, soft liquidation and hard liquidation labels without overlap. + screenshot: true + - name: Borrow modal + instruction: Open Manage Loan tab and open Borrow modal. + expected: Borrow modal is open with amount input and target LTV section. + screenshot: true + - name: Repay modal + instruction: Open Repay modal from Manage Loan tab. + expected: Repay modal is open with full repayment toggle and amount controls. + screenshot: true diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index cac6557310f..1524953ad32 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2392,9 +2392,12 @@ "chainflipLending": { "headerDescription": "Supply assets to earn yield or borrow against collateral on Chainflip.", "overview": "Overview", + "myDashboard": "My Dashboard", + "allMarkets": "All Markets", + "manageLoan": "Manage Loan", "supply": { "title": "Supply", - "amount": "Supply amount", + "amount": "Amount", "available": "Available to supply", "availableTooltip": "Your free balance on Chainflip State Chain available for lending", "minimumSupply": "Minimum supply: %{amount}", @@ -2416,7 +2419,7 @@ }, "borrow": { "title": "Borrow", - "amount": "Borrow amount", + "amount": "Amount", "available": "Available to borrow", "maxLtv": "Max LTV (80%)", "currentLtv": "Current LTV", @@ -2460,7 +2463,8 @@ "currentLtv": "Current LTV", "borrowCapacity": "Borrow Capacity", "borrowPowerUsed": "Borrow Power Used", - "availableToBorrow": "Available to Borrow" + "availableToBorrow": "Available to Borrow", + "actionPaused": "This action is temporarily paused on Chainflip" }, "supplyApy": "Supply APY", "supplyApyTooltip": "Annual percentage yield earned by supplying assets to this pool.", @@ -2477,7 +2481,7 @@ "title": "Collateral", "add": "Add Collateral", "remove": "Remove Collateral", - "amount": "Collateral amount", + "amount": "Amount", "availableToAdd": "Available to add", "availableToRemove": "Available to remove", "confirmAddTitle": "Confirm Add Collateral", @@ -2564,7 +2568,7 @@ "myBalancesTitle": "Chainflip Lending - My Balances", "withdraw": { "title": "Withdraw", - "amount": "Withdraw amount", + "amount": "Amount", "available": "Available to withdraw", "availableTooltip": "Your supply position in the lending pool", "confirmTitle": "Confirm Withdrawal", @@ -2611,7 +2615,7 @@ }, "repay": { "title": "Repay", - "amount": "Repay amount", + "amount": "Amount", "outstanding": "Outstanding debt", "fullRepayment": "Full repayment", "partialRepayment": "Partial repayment", diff --git a/src/pages/ChainflipLending/ChainflipLending.tsx b/src/pages/ChainflipLending/ChainflipLending.tsx index 58fdf2a66de..842df2cee7b 100644 --- a/src/pages/ChainflipLending/ChainflipLending.tsx +++ b/src/pages/ChainflipLending/ChainflipLending.tsx @@ -4,11 +4,9 @@ import { Route, Routes } from 'react-router-dom' import { ComponentErrorBoundary } from '@/components/ErrorBoundary' import { ChainflipLendingAccountProvider } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' import { Markets } from '@/pages/ChainflipLending/components/Markets' -import { MyBalances } from '@/pages/ChainflipLending/components/MyBalances' import { Pool } from '@/pages/ChainflipLending/Pool/Pool' const overview = -const myBalances = const pool = export const ChainflipLending = memo(() => ( @@ -16,7 +14,7 @@ export const ChainflipLending = memo(() => ( - + diff --git a/src/pages/ChainflipLending/Pool/Pool.tsx b/src/pages/ChainflipLending/Pool/Pool.tsx index 00a18b29a3b..247626169b0 100644 --- a/src/pages/ChainflipLending/Pool/Pool.tsx +++ b/src/pages/ChainflipLending/Pool/Pool.tsx @@ -45,6 +45,7 @@ import { useChainflipAccount } from '@/pages/ChainflipLending/hooks/useChainflip import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' +import { useChainflipSafeModeStatuses } from '@/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses' import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' import { selectAssetById } from '@/state/slices/assetsSlice/selectors' import { selectAccountIdsByAccountNumberAndChainId } from '@/state/slices/portfolioSlice/selectors' @@ -58,19 +59,14 @@ enum PoolTabIndex { Supply = 0, Deposit = 1, Collateral = 2, - Borrow = 3, - Repay = 4, + ManageLoan = 3, } -const SUPPLY_TAB_ITEMS = [ +const ACTION_TAB_ITEMS = [ { label: 'chainflipLending.supply.title', index: PoolTabIndex.Supply }, { label: 'chainflipLending.depositToChainflip', index: PoolTabIndex.Deposit }, -] - -const BORROW_TAB_ITEMS = [ { label: 'chainflipLending.collateral.title', index: PoolTabIndex.Collateral }, - { label: 'chainflipLending.borrow.title', index: PoolTabIndex.Borrow }, - { label: 'chainflipLending.repay.title', index: PoolTabIndex.Repay }, + { label: 'chainflipLending.manageLoan', index: PoolTabIndex.ManageLoan }, ] type PoolHeaderProps = { @@ -151,8 +147,7 @@ export const Pool = () => { const location = useLocation() const { dispatch: walletDispatch } = useWallet() const { accountId, accountNumber, setAccountId } = useChainflipLendingAccount() - const [supplyTabIndex, setSupplyTabIndex] = useState(PoolTabIndex.Supply) - const [borrowTabIndex, setBorrowTabIndex] = useState(PoolTabIndex.Collateral) + const [actionTabIndex, setActionTabIndex] = useState(PoolTabIndex.Supply) const chainflipLendingModal = useModal('chainflipLending') const handleConnectWallet = useCallback( @@ -174,7 +169,6 @@ export const Pool = () => { loansWithFiat, totalCollateralFiat, totalBorrowedFiat: userBorrowedFiat, - isLoading: isLoanLoading, } = useChainflipLoanAccount() const isVoluntaryLiquidationActive = useMemo(() => { @@ -255,26 +249,15 @@ export const Pool = () => { const headerComponent = useMemo(() => , [poolAssetId]) - const supplyTabHeader = useMemo( - () => ( - - ), - [supplyTabIndex], - ) - - const borrowTabHeader = useMemo( + const actionTabHeader = useMemo( () => ( ), - [borrowTabIndex], + [actionTabIndex], ) const poolCollateral = useMemo( @@ -345,6 +328,17 @@ export const Pool = () => { [poolCollateral?.amountFiat], ) const hasLoans = useMemo(() => Boolean(poolLoan), [poolLoan]) + const { + canDepositToChainflip, + canWithdrawFromChainflip, + canSupply, + canWithdrawSupply, + canAddCollateral, + canRemoveCollateral, + canBorrow, + canLiquidate, + isLoading: isSafeModeStatusesLoading, + } = useChainflipSafeModeStatuses(poolAssetId) const { thresholds } = useChainflipLtvThresholds() @@ -391,12 +385,46 @@ export const Pool = () => { if (!asset) return null + const actionPausedLabel = translate('chainflipLending.pool.actionPaused') + const supplyTooltipLabel = !canSupply + ? actionPausedLabel + : !hasFreeBalance + ? translate('chainflipLending.pool.noFreeBalance') + : undefined + const withdrawSupplyTooltipLabel = !canWithdrawSupply + ? actionPausedLabel + : !hasSupplyPosition + ? translate('chainflipLending.pool.noSupplyPosition') + : undefined + const depositTooltipLabel = !canDepositToChainflip ? actionPausedLabel : undefined + const withdrawFromChainflipTooltipLabel = !canWithdrawFromChainflip + ? actionPausedLabel + : !hasFreeBalance + ? translate('chainflipLending.pool.noFreeBalance') + : undefined + const addCollateralTooltipLabel = !canAddCollateral + ? actionPausedLabel + : !hasFreeBalance + ? translate('chainflipLending.pool.noFreeBalance') + : undefined + const removeCollateralTooltipLabel = !canRemoveCollateral + ? actionPausedLabel + : !hasPoolCollateral + ? translate('chainflipLending.pool.noCollateral') + : undefined + const borrowTooltipLabel = !canBorrow + ? actionPausedLabel + : !hasCollateral + ? translate('chainflipLending.pool.noCollateral') + : undefined + const liquidateTooltipLabel = !canLiquidate ? actionPausedLabel : undefined + return (
- + {translate('chainflipLending.supplyStats')} @@ -446,7 +474,7 @@ export const Pool = () => { - + {translate('chainflipLending.borrowStats')} @@ -504,126 +532,25 @@ export const Pool = () => { alignSelf='flex-start' gap={4} > - - - - - {translate('chainflipLending.yourPosition')} - - {accountId ? ( - - ) : null} - - - - - - {translate('chainflipLending.supplied')} - - - - - {accountId ? ( - - - - ) : null} - - - - {translate('chainflipLending.collateral.title')} - - - - - {accountId ? ( - - - - ) : null} - - - - - - {translate('chainflipLending.borrow.borrowed')} - - - - - {accountId && poolLoan ? ( - - - - ) : null} - - {hasLoans && accountId ? ( - - - {translate('chainflipLending.pool.currentLtv')} - - - - {currentLtvPercent}% - - - - ) : null} - - {!accountId ? ( - - ) : null} - - - - - + - - {supplyTabHeader} + {accountId && ( + + + + + + )} + + {actionTabHeader} - + @@ -650,49 +577,65 @@ export const Pool = () => { - - - - - + + + + - {translate('common.withdraw')} - - + + + - + @@ -717,52 +660,62 @@ export const Pool = () => { /> - - - + + + + - {translate('common.withdraw')} - - + + + - - - - - - - - - {borrowTabHeader} - - + @@ -805,49 +758,65 @@ export const Pool = () => { )} - - - - - + + + + - {translate('chainflipLending.collateral.remove')} - - + + + - + @@ -889,59 +858,6 @@ export const Pool = () => { /> )} - - - - - - - - - - {translate('chainflipLending.repay.outstanding')} - - - - {poolLoan && ( - - )} - - - {hasLoans && ( - - - {translate('chainflipLending.pool.currentLtv')} - - - {currentLtvPercent}% - - - )} {translate('chainflipLending.pool.freeBalance')} @@ -953,25 +869,58 @@ export const Pool = () => { fontWeight='medium' /> - - - + + + + + + + + + + + + @@ -979,6 +928,24 @@ export const Pool = () => { + {!accountId && ( + + + + + + )} + {hasLoans && accountId && ( { {translate('chainflipLending.voluntaryLiquidation.inProgress')} )} - + + diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/Borrow.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/Borrow.tsx index 1947309a10a..1e910258223 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/Borrow.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/Borrow.tsx @@ -1,6 +1,6 @@ import type { AssetId } from '@shapeshiftoss/caip' import { AnimatePresence } from 'framer-motion' -import { lazy, memo, Suspense, useEffect, useMemo } from 'react' +import { lazy, memo, Suspense, useEffect, useMemo, useState } from 'react' import { BorrowMachineCtx } from './BorrowMachineContext' @@ -22,47 +22,54 @@ type BorrowProps = { assetId: AssetId } -export const Borrow = memo(({ assetId }: BorrowProps) => { +export const Borrow = memo(({ assetId: initialAssetId }: BorrowProps) => { + const [activeAssetId, setActiveAssetId] = useState(initialAssetId) const { connectedType } = useWallet().state const isNativeWallet = connectedType === KeyManager.Native - const input = useMemo(() => ({ assetId, isNativeWallet }), [assetId, isNativeWallet]) + const input = useMemo( + () => ({ assetId: activeAssetId, isNativeWallet }), + [activeAssetId, isNativeWallet], + ) return ( - - + + ) }) -const BorrowContent = memo(({ assetId }: { assetId: AssetId }) => { - const isInput = BorrowMachineCtx.useSelector(s => s.matches('input')) - const isConfirm = BorrowMachineCtx.useSelector(s => s.matches('confirm')) - const isExecuting = BorrowMachineCtx.useSelector(s => s.hasTag('executing')) - const isSuccess = BorrowMachineCtx.useSelector(s => s.matches('success')) - const isError = BorrowMachineCtx.useSelector(s => s.matches('error')) +const BorrowContent = memo( + ({ assetId, onAssetChange }: { assetId: AssetId; onAssetChange: (assetId: AssetId) => void }) => { + const isInput = BorrowMachineCtx.useSelector(s => s.matches('input')) + const isConfirm = BorrowMachineCtx.useSelector(s => s.matches('confirm')) + const isExecuting = BorrowMachineCtx.useSelector(s => s.hasTag('executing')) + const isSuccess = BorrowMachineCtx.useSelector(s => s.matches('success')) + const isError = BorrowMachineCtx.useSelector(s => s.matches('error')) - useLtvSync() + useLtvSync() - const page = useMemo(() => { - if (isInput) return 'input' as const - if (isConfirm) return 'confirm' as const - if (isExecuting) return 'executing' as const - if (isSuccess) return 'success' as const - if (isError) return 'error' as const - return 'input' as const - }, [isInput, isConfirm, isExecuting, isSuccess, isError]) + const page = useMemo(() => { + if (isInput) return 'input' as const + if (isConfirm) return 'confirm' as const + if (isExecuting) return 'executing' as const + if (isSuccess) return 'success' as const + if (isError) return 'error' as const + return 'input' as const + }, [isInput, isConfirm, isExecuting, isSuccess, isError]) - return ( - - - {page === 'input' && } - {(page === 'confirm' || page === 'executing' || page === 'success' || page === 'error') && ( - - )} - - - ) -}) + return ( + + + {page === 'input' && } + {(page === 'confirm' || + page === 'executing' || + page === 'success' || + page === 'error') && } + + + ) + }, +) const useLtvSync = () => { const actorRef = BorrowMachineCtx.useActorRef() diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx index 3bfaef5b062..ff4f1c44aa4 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx @@ -1,5 +1,6 @@ import { Button, CardBody, CardFooter, Flex, Stack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' import type { NumberFormatValues } from 'react-number-format' @@ -10,25 +11,28 @@ import { BorrowMachineCtx } from './BorrowMachineContext' import { LtvGauge } from './LtvGauge' import { Amount } from '@/components/Amount/Amount' -import { AssetIcon } from '@/components/AssetIcon' +import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { useModal } from '@/hooks/useModal/useModal' import { bnOrZero } from '@/lib/bignumber/bignumber' +import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' import { useChainflipBorrowMinimums } from '@/pages/ChainflipLending/hooks/useChainflipBorrowMinimums' import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' import { useChainflipOraclePrice } from '@/pages/ChainflipLending/hooks/useChainflipOraclePrices' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' -import { selectAssetById } from '@/state/slices/selectors' +import { selectAssetById, selectAssets } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' type BorrowInputProps = { assetId: AssetId + onAssetChange: (assetId: AssetId) => void } -export const BorrowInput = ({ assetId }: BorrowInputProps) => { +export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { const translate = useTranslate() const { number: { localeParts }, @@ -89,6 +93,33 @@ export const BorrowInput = ({ assetId }: BorrowInputProps) => { const projectedLtvDecimal = useMemo(() => projectedLtvBps / 10000, [projectedLtvBps]) + const assetIds = useMemo(() => Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[], []) + + const assets = useAppSelector(selectAssets) + + const lendingAssets = useMemo(() => { + return assetIds.reduce((acc, assetId) => { + const asset = assets[assetId] + if (asset) acc.push(asset) + return acc + }, []) + }, [assetIds, assets]) + + const buyAssetSearch = useModal('buyAssetSearch') + + const handleAssetClick = useCallback(() => { + buyAssetSearch.open({ + onAssetClick: (asset: Asset) => onAssetChange(asset.assetId), + title: 'chainflipLending.borrow.title', + assets: lendingAssets, + }) + }, [buyAssetSearch, onAssetChange, lendingAssets]) + + const handleAssetChange = useCallback( + (asset: Asset) => onAssetChange(asset.assetId), + [onAssetChange], + ) + const handleInputChange = useCallback((values: NumberFormatValues) => { setInputValue(values.value) }, []) @@ -129,18 +160,22 @@ export const BorrowInput = ({ assetId }: BorrowInputProps) => { - - - - {asset.symbol} - - + {translate('chainflipLending.borrow.amount')} { fontWeight='medium' /> @@ -245,6 +286,7 @@ export const WithdrawInput = ({ assetId }: WithdrawInputProps) => { py={4} > { const translate = useTranslate() const navigate = useNavigate() @@ -132,7 +116,6 @@ export const ChainflipLendingHeader = () => { )} - ) diff --git a/src/pages/ChainflipLending/components/Markets.tsx b/src/pages/ChainflipLending/components/Markets.tsx index c87aae070ac..7e559556420 100644 --- a/src/pages/ChainflipLending/components/Markets.tsx +++ b/src/pages/ChainflipLending/components/Markets.tsx @@ -22,6 +22,7 @@ import { Text } from '@/components/Text' import { bnOrZero } from '@/lib/bignumber/bignumber' import { permillToDecimal } from '@/lib/chainflip/utils' import { ChainflipLendingHeader } from '@/pages/ChainflipLending/components/ChainflipLendingHeader' +import { MyBalancesList } from '@/pages/ChainflipLending/components/MyBalances' import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' @@ -136,36 +137,56 @@ export const Markets = () => { return (
- - - - - - - - - - - - - - - - - - - - - - - {marketRows} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {marketRows} + +
) diff --git a/src/pages/ChainflipLending/components/MyBalances.tsx b/src/pages/ChainflipLending/components/MyBalances.tsx index 2a1c238b6c9..c5238a2eaf8 100644 --- a/src/pages/ChainflipLending/components/MyBalances.tsx +++ b/src/pages/ChainflipLending/components/MyBalances.tsx @@ -1,21 +1,15 @@ import type { GridProps } from '@chakra-ui/react' -import { Button, Center, Flex, SimpleGrid, Skeleton, Stack } from '@chakra-ui/react' +import { Button, Flex, SimpleGrid, Skeleton, Stack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { fromAssetId } from '@shapeshiftoss/caip' import { useCallback, useMemo } from 'react' -import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' -import { Main } from '@/components/Layout/Main' -import { SEO } from '@/components/Layout/Seo' import { AssetCell } from '@/components/StakingVaults/Cells' import { Text } from '@/components/Text' -import { WalletActions } from '@/context/WalletProvider/actions' -import { useWallet } from '@/hooks/useWallet/useWallet' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' -import { ChainflipLendingHeader } from '@/pages/ChainflipLending/components/ChainflipLendingHeader' import type { ChainflipFreeBalanceWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' import type { @@ -148,10 +142,8 @@ const BalanceRow = ({ ) } -export const MyBalances = () => { - const translate = useTranslate() +export const MyBalancesList = () => { const navigate = useNavigate() - const { dispatch: walletDispatch } = useWallet() const { accountId, accountNumber } = useChainflipLendingAccount() const { freeBalances, isLoading } = useChainflipFreeBalances() const { supplyPositions, isLoading: isPositionsLoading } = useChainflipSupplyPositions() @@ -164,13 +156,6 @@ export const MyBalances = () => { [navigate], ) - const handleConnectWallet = useCallback( - () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), - [walletDispatch], - ) - - const headerComponent = useMemo(() => , []) - const freeBalancesByAssetId = useMemo( () => freeBalances.reduce>>( @@ -214,17 +199,6 @@ export const MyBalances = () => { ) const balanceRows = useMemo(() => { - if (!accountId) { - return ( -
- - -
- ) - } - if (isLoading || isPositionsLoading || isLoanLoading) { return Array.from({ length: 5 }).map((_, i) => ) } @@ -242,7 +216,6 @@ export const MyBalances = () => { /> )) }, [ - accountId, accountNumber, isLoading, isPositionsLoading, @@ -252,41 +225,38 @@ export const MyBalances = () => { collateralByAssetId, loansByAssetId, handleDeposit, - handleConnectWallet, - translate, ]) + if (!accountId) return null + return ( -
- - - - - - - - - - - - - - - - - - - - - {balanceRows} - -
+ + + + + + + + + + + + + + + + + + + + {balanceRows} + ) } diff --git a/src/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses.ts b/src/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses.ts new file mode 100644 index 00000000000..093eadc87d9 --- /dev/null +++ b/src/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses.ts @@ -0,0 +1,46 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { reactQueries } from '@/react-queries' + +const THIRTY_SECONDS = 30_000 + +export const useChainflipSafeModeStatuses = (assetId: AssetId) => { + const { data, isLoading } = useQuery({ + ...reactQueries.chainflipLending.safeModeStatuses(), + staleTime: THIRTY_SECONDS, + }) + + return useMemo(() => { + const cfAsset = CHAINFLIP_LENDING_ASSET_BY_ASSET_ID[assetId] + const lendingPools = data?.lending_pools + const liquidityProvider = data?.liquidity_provider + + const includesAsset = ( + assets: + | { + chain: string + asset: string + }[] + | undefined, + ) => { + if (!cfAsset || !assets) return false + + return assets.some(asset => asset.chain === cfAsset.chain && asset.asset === cfAsset.asset) + } + + return { + canDepositToChainflip: Boolean(liquidityProvider?.deposit_enabled), + canWithdrawFromChainflip: Boolean(liquidityProvider?.withdrawal_enabled), + canSupply: includesAsset(lendingPools?.add_lender_funds), + canWithdrawSupply: includesAsset(lendingPools?.withdraw_lender_funds), + canAddCollateral: includesAsset(lendingPools?.add_collateral), + canRemoveCollateral: includesAsset(lendingPools?.remove_collateral), + canBorrow: includesAsset(lendingPools?.borrowing), + canLiquidate: Boolean(lendingPools?.liquidations_enabled), + isLoading, + } + }, [assetId, data, isLoading]) +} From 4c99f2c63313172fae47ecc16f37b0de7f251ad4 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 11:03:02 +0100 Subject: [PATCH 09/31] feat: chainflip lending action center integration (#12064) --- ...inflip-lending-action-center-pr-12064.yaml | 27 + e2e/fixtures/chainflip-lending.yaml | 661 ++++++++++++++++++ src/assets/translations/en/main.json | 48 ++ .../Header/ActionCenter/ActionCenter.tsx | 4 + .../components/ChainflipLendingActionCard.tsx | 229 ++++++ .../ChainflipLendingNotification.tsx | 94 +++ .../Pool/components/Borrow/BorrowConfirm.tsx | 2 + .../components/Borrow/CollateralConfirm.tsx | 2 + .../Pool/components/Borrow/RepayConfirm.tsx | 2 + .../Borrow/hooks/useBorrowActionCenter.tsx | 47 ++ .../hooks/useCollateralActionCenter.tsx | 51 ++ .../Borrow/hooks/useRepayActionCenter.tsx | 47 ++ .../components/Deposit/DepositConfirm.tsx | 2 + .../Deposit/hooks/useDepositActionCenter.tsx | 46 ++ .../Pool/components/Egress/EgressConfirm.tsx | 2 + .../Egress/hooks/useEgressActionCenter.tsx | 47 ++ .../Pool/components/Supply/SupplyConfirm.tsx | 2 + .../Supply/hooks/useSupplyActionCenter.tsx | 47 ++ .../components/Withdraw/WithdrawConfirm.tsx | 2 + .../hooks/useWithdrawActionCenter.tsx | 47 ++ .../hooks/useChainflipLendingAction.tsx | 159 +++++ src/state/slices/actionSlice/selectors.ts | 16 + src/state/slices/actionSlice/types.ts | 32 + 23 files changed, 1616 insertions(+) create mode 100644 e2e/fixtures/chainflip-lending-action-center-pr-12064.yaml create mode 100644 e2e/fixtures/chainflip-lending.yaml create mode 100644 src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx create mode 100644 src/components/Layout/Header/ActionCenter/components/Notifications/ChainflipLendingNotification.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Borrow/hooks/useBorrowActionCenter.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Borrow/hooks/useCollateralActionCenter.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Borrow/hooks/useRepayActionCenter.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Deposit/hooks/useDepositActionCenter.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Egress/hooks/useEgressActionCenter.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Supply/hooks/useSupplyActionCenter.tsx create mode 100644 src/pages/ChainflipLending/Pool/components/Withdraw/hooks/useWithdrawActionCenter.tsx create mode 100644 src/pages/ChainflipLending/hooks/useChainflipLendingAction.tsx diff --git a/e2e/fixtures/chainflip-lending-action-center-pr-12064.yaml b/e2e/fixtures/chainflip-lending-action-center-pr-12064.yaml new file mode 100644 index 00000000000..8e1c702969e --- /dev/null +++ b/e2e/fixtures/chainflip-lending-action-center-pr-12064.yaml @@ -0,0 +1,27 @@ +name: Chainflip Lending Action Center PR 12064 +description: > + Evidence-only fixture for PR 12064. Assumes the current browser session + already executed Chainflip lending USDC operations and the Action Center + contains the resulting cards. Focuses only on notification center copy, + expanded details, and egress transaction affordances. +route: /#/chainflip-lending/pool/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 +steps: + - name: Verify confirmed Chainflip lending cards + instruction: > + Open the notification center drawer and verify the confirmed Chainflip + lending cards for deposit, borrow, repay, add collateral, and remove + collateral show sane user copy with expanded details for operation, + amount, and transaction id. + expected: > + Confirmed Chainflip lending cards are visible with expanded details and + no untranslated keys. + screenshot: true + - name: Verify egress card transaction affordance + instruction: > + Verify the egress card shows the withdraw-from-Chainflip copy, expanded + details include the egress transaction id, and a View Transaction button + is present for the external explorer link. + expected: > + The egress Action Center card shows human copy, egress tx id, and a View + Transaction button. + screenshot: true diff --git a/e2e/fixtures/chainflip-lending.yaml b/e2e/fixtures/chainflip-lending.yaml new file mode 100644 index 00000000000..7e1740c38e9 --- /dev/null +++ b/e2e/fixtures/chainflip-lending.yaml @@ -0,0 +1,661 @@ +name: Chainflip Lending +description: > + End-to-end round-trip across all 3 fund buckets on the USDC pool. + Assumes the user has an EXISTING Chainflip Lending position (account + already registered, refund address set, prior deposits exist). + + Round-trip sequence: + Navigate → Deposit ($0.50-$2) → Supply → Withdraw Supply → + Add Collateral → Borrow (small, safe LTV) → Repay → + Remove Collateral → Egress + + Each step verifies: + - Toast notification with correct domain language + - Action Center card with correct status + operation type + + === THREE FUND BUCKETS === + + All on Chainflip State Chain: + - Free balance: staging area. Deposits land here. + - Supply: allocated to lending pools, earning yield. + - Collateral: backing loans, determines borrowing power. + + Funds MUST move through free balance between supply and collateral. + + === TRANSACTION FLOW === + + All State Chain ops use EIP-712 signing: + 1. App SCALE-encodes the call + 2. User signs structured typed data in wallet + 3. App submits signed extrinsic to State Chain + 4. App polls State Chain for confirmation (~6s intervals) + + Deposit is different: EIP-712 to open channel → EVM tx to channel + address → State Chain witnesses the deposit. + + === TIMING === + + - EVM on-chain tx (deposit send): 30-60s confirmation + - State Chain confirmation polling: 5-10s per confirmation + - Egress to wallet: 60-300s (on-chain broadcast from CF vaults) + - After ANY action button: wait up to 180s total + + === HARD CONSTRAINTS === + + 1. USDC pool only. Single pool round-trip. + 2. Existing position — account is registered, refund address set. + 3. $2 max deposit ceiling. Start at $0.50, increment to $1 then $2 + if amount is below the protocol minimum. + 4. Supply minimum: ~$100 from protocol. If existing free balance is + insufficient, use whatever free balance exists. If under $100, + skip supply with SKIPPED (not passed, not failed). + 5. Borrow: smallest possible amount ($10 minimum). Keep LTV << 80%. + 6. Collateral: $10 minimum for updates. + 7. Skipped steps = SKIPPED with explanation. Never mark skipped as + passed or failed. + 8. No Bitcoin deposits. + 9. No force-funding swaps — work with existing balances. + 10. Report every operation with action, asset, amount, before/after. + + === ACTION CENTER VERIFICATION === + + After EVERY successful flow: + 1. Wait up to 10s for toast notification (bottom-right desktop) + 2. Read toast text — must mention asset and amount + 3. Click toast → Action Center drawer opens + 4. Verify latest card: + - Type label: "Chainflip Lending" + - Status: green "Confirmed" + - Description: correct operation + amount + - Asset icon: USDC + 5. Close Action Center + + If toast missing within 10s: SOFT FAIL, verify Action Center + manually via bell icon. + + === NATIVE WALLET === + + At each signing step, a "Confirm" button may appear for hardware + wallets. Click it. If password prompt: enter $NATIVE_WALLET_PASSWORD. + +route: /chainflip-lending +depends_on: + - wallet-health.yaml + +steps: + # ======================================================================== + # PHASE 0: Navigate and record baseline + # ======================================================================== + + - name: Ensure Chainflip Lending feature flag enabled + instruction: > + eval "window.location.hash = '/'" + Wait 2 seconds. + + Verify the current session has the Chainflip Lending feature enabled + before navigating into the flow. + + If you can inspect Redux state, confirm: + preferences.featureFlags.ChainflipLending === true + + If the Chainflip Lending route/header is unavailable in this session, + mark this scenario SKIPPED with note: + "ChainflipLending feature flag is disabled for this session." + expected: > + Chainflip Lending is enabled for this session before flow validation starts. + screenshot: true + + - name: Navigate to Chainflip Lending + instruction: > + eval "window.location.hash = '/chainflip-lending'" + Wait 5 seconds. + + Verify: + 1. Page loads with "Chainflip Lending" header + 2. Markets table shows pools (BTC, ETH, SOL, USDC, USDT) + 3. Stats cards show Total Supplied, Available Liquidity, + Total Borrowed (all > $0) + expected: > + Chainflip Lending page loads with live pool data. + screenshot: true + + - name: Navigate to USDC pool and record baseline + instruction: > + Click USDC row in markets table, or: + eval "window.location.hash = '/chainflip-lending/pool/usdc'" + Wait 5 seconds. + + Record baseline "Your Position" values exactly: + - Free Balance: ___ USDC + - Supplied: ___ USDC + - Collateral: ___ USDC + - Any active loans? Amount: ___ + + Also record pool stats: supply APY, borrow rate, utilisation. + + These baselines are critical for verifying each subsequent step. + expected: > + USDC pool page loads. Baseline position recorded. + screenshot: true + + # ======================================================================== + # PHASE 1: Deposit (wallet → free balance) + # $0.50 start, increment if below minimum. $2 absolute max. + # ======================================================================== + + - name: Deposit USDC > Open modal + instruction: > + Click the "Deposit" button on the USDC pool page. + Modal opens with amount input. + + Verify: + 1. Title mentions "Deposit" + 2. Available balance shows wallet USDC balance + 3. Current free balance indicator visible + 4. This is a returning user — no refund address step expected + expected: > + Deposit modal opens showing wallet USDC balance. + screenshot: true + + - name: Deposit USDC > Enter amount ($0.50 start) + instruction: > + Enter "0.50" in the amount input. Wait 2 seconds. + + Check for minimum deposit warning: + - If NO warning and submit enabled: proceed with $0.50 + - If minimum warning appears: clear input, try "1.00" + - If still below minimum: clear input, try "2.00" + - If $2.00 is still below minimum: SKIP entire deposit phase + as SKIPPED with note "minimum deposit exceeds $2 ceiling" + + Once a valid amount is accepted, click submit/continue. + Wait for confirm screen. Verify amount shown. + Click "Confirm & Deposit". + expected: > + Valid deposit amount entered ($0.50-$2) and confirmed. + OR SKIPPED if minimum exceeds $2. + screenshot: true + + - name: Deposit USDC > Execute deposit + instruction: > + If previous step was SKIPPED, mark this SKIPPED too. + + The stepper shows progress. For returning users: + - Open Channel (EIP-712 sign) + - Send Deposit (EVM on-chain tx) + - Confirming (State Chain witness) + + For native wallet: click "Confirm" at each signing step. + If password prompt: enter $NATIVE_WALLET_PASSWORD. + + Wait up to 180 seconds. Poll snapshots every 15 seconds. + + Expected: success screen with deposit confirmation. + expected: > + Deposit completes. Success screen shows deposited amount. + screenshot: true + + - name: Deposit USDC > Verify toast + Action Center + instruction: > + If deposit was SKIPPED, mark this SKIPPED too. + + Wait up to 10 seconds for toast notification. + Toast should mention deposit amount and "Chainflip". + + If toast visible: + 1. Record toast text + 2. Click toast → Action Center drawer opens + 3. Verify latest card: + - Type: "Chainflip Lending" + - Status: "Confirmed" (green) + - Mentions deposit and USDC + 4. Close Action Center + + If no toast: open Action Center via bell icon. SOFT FAIL. + expected: > + Toast notification for deposit. Action Center card correct. + screenshot: true + soft_fail: true + + - name: Deposit USDC > Verify free balance increase + instruction: > + If deposit was SKIPPED, mark this SKIPPED too. + + Click "Done". Wait 3 seconds. + + Verify free balance increased by the deposited amount + compared to Phase 0 baseline. + + Record new free balance. + + REPORT: "DEPOSIT: X USDC. Free balance: [before] → [after]." + expected: > + Free balance increased by deposit amount. + screenshot: true + + # ======================================================================== + # PHASE 2: Supply (free balance → lending pool) + # Minimum $100. If free balance < $100, SKIP. + # ======================================================================== + + - name: Supply USDC > Check feasibility + instruction: > + Check current free balance on the USDC pool page. + + If free balance < $100: + SKIP entire supply phase (steps through "Verify position") + with note: "Free balance ($X) below $100 supply minimum. + Cannot supply without larger deposit (exceeds $2 ceiling)." + + If free balance >= $100: + Click "Supply" button. Modal opens. + Verify available balance shows free balance amount. + expected: > + Supply feasible (free balance >= $100) or SKIPPED. + screenshot: true + + - name: Supply USDC > Enter amount and confirm + instruction: > + If previous step was SKIPPED, mark this SKIPPED too. + + Enter "101" in amount input (just above $100 minimum). + If free balance < 101 but >= 100, enter "100" or use Max. + + Verify no minimum warning. Click submit. + Verify confirm screen. Click "Confirm & Supply". + expected: > + Supply amount confirmed. + screenshot: true + + - name: Supply USDC > Execute + instruction: > + If supply was SKIPPED, mark this SKIPPED too. + + Stepper: Signing (EIP-712) → Confirming. + For native wallet: click "Confirm" button. + Wait up to 120 seconds. + + Expected: success screen "Supply Successful". + expected: > + Supply completes successfully. + screenshot: true + + - name: Supply USDC > Verify toast + Action Center + instruction: > + If supply was SKIPPED, mark this SKIPPED too. + + Wait 10s for toast. Should mention supply + USDC + amount. + + If toast visible: + 1. Click toast → Action Center opens + 2. Verify latest card: "Chainflip Lending", "Confirmed", + mentions supply + 3. Close drawer + + If no toast: check Action Center manually. SOFT FAIL. + expected: > + Toast + Action Center card for supply operation. + screenshot: true + soft_fail: true + + - name: Supply USDC > Verify position + instruction: > + If supply was SKIPPED, mark this SKIPPED too. + + Click "Done". Wait 3 seconds. + + Verify: + - Supplied increased by supply amount + - Free balance decreased by supply amount + + REPORT: "SUPPLY: X USDC. Free: [before] → [after]. + Supplied: [before] → [after]." + expected: > + Supplied balance increased. Free balance decreased. + screenshot: true + + # ======================================================================== + # PHASE 3: Withdraw supply (lending pool → free balance) + # Only if supply was executed. + # ======================================================================== + + - name: Withdraw Supply > Open modal + instruction: > + If supply phase was SKIPPED, mark this SKIPPED too. + + Click "Withdraw Supply" or "Withdraw" button. + Modal opens. Verify available shows supply position. + + Do NOT check "Also withdraw to wallet" — we need funds + in free balance for collateral next. + expected: > + Withdraw modal shows supply position as available. + screenshot: true + + - name: Withdraw Supply > Full withdrawal + instruction: > + If withdraw was SKIPPED, mark this SKIPPED too. + + Click "Max" to withdraw the full supply position. + Click submit. Verify confirm screen. Click "Confirm & Withdraw". + + For native wallet: click "Confirm". + Wait up to 120 seconds. + + Expected: success screen "Withdrawal Successful". + expected: > + Full withdrawal completes. + screenshot: true + + - name: Withdraw Supply > Verify toast + Action Center + instruction: > + If withdraw was SKIPPED, mark this SKIPPED too. + + Wait 10s for toast. Should mention withdrawal + USDC. + + If toast visible: + 1. Click toast → Action Center + 2. Verify card: "Chainflip Lending", "Confirmed", withdrawal + 3. Close drawer + + If no toast: SOFT FAIL, check manually. + expected: > + Toast + Action Center card for withdrawal. + screenshot: true + soft_fail: true + + - name: Withdraw Supply > Verify balances + instruction: > + If withdraw was SKIPPED, mark this SKIPPED too. + + Click "Done". Wait 3 seconds. + + Verify: + - Free balance restored (supply amount returned) + - Supplied back to previous level (or ~0 if full withdraw) + + REPORT: "WITHDRAW: X USDC. Supplied: [before] → [after]. + Free: [before] → [after]." + expected: > + Free balance restored. Supply position cleared or reduced. + screenshot: true + + # ======================================================================== + # PHASE 4: Add Collateral (free balance → collateral) + # Minimum $10. If no collateral UI, SKIP phases 4-7. + # ======================================================================== + + - name: Add Collateral > Check availability + instruction: > + On USDC pool page, look for "Collateral" tab, "Add Collateral" + button, or collateral section. + + If NOT present: + SKIP phases 4-7 with note: "Collateral/Borrow flow not + available in this build (PR #12026 not merged)." + + If present and free balance < $10: + SKIP with note: "Free balance below $10 collateral minimum." + + If present and free balance >= $10: proceed. + expected: > + Collateral UI available and free balance sufficient, or SKIPPED. + screenshot: true + + - name: Add Collateral > Enter amount and execute + instruction: > + If SKIPPED, mark this SKIPPED too. + + Click "Add Collateral". Modal opens. + Enter amount: use all available free balance minus $1 buffer, + minimum $10. If free balance is $50, enter "49". + + Click submit. Confirm. Sign (native wallet: click Confirm). + Wait up to 120 seconds. + + Expected: success screen. + expected: > + Collateral added successfully. + screenshot: true + + - name: Add Collateral > Verify toast + balances + instruction: > + If SKIPPED, mark this SKIPPED too. + + Check toast (10s). Verify Action Center card if toast appears. + + After "Done", verify: + - Collateral increased + - Free balance decreased + + REPORT: "ADD COLLATERAL: X USDC. Free: [before] → [after]. + Collateral: [before] → [after]." + expected: > + Collateral balance increased. Action Center card present. + screenshot: true + soft_fail: true + + # ======================================================================== + # PHASE 5: Borrow (against collateral) + # Minimum $10 for loan update, $100 for new loan. + # Keep LTV well below 80%. + # ======================================================================== + + - name: Borrow > Open modal + instruction: > + If collateral phase was SKIPPED, mark this SKIPPED too. + + Look for "Borrow" tab or button. If not present: SKIP. + + Click it. Modal shows: + - Available to borrow (collateral × 80% LTV - existing debt) + - Current LTV + - Amount input + + Enter "10" (minimum for loan update, or $100 if new loan + and available). Use the smallest valid amount. + + If available borrow capacity < $10: SKIP with note + "Insufficient borrowing capacity." + expected: > + Borrow modal open with amount entered. + screenshot: true + soft_fail: true + + - name: Borrow > Execute + instruction: > + If SKIPPED, mark this SKIPPED too. + + Click submit. Confirm. Sign. Wait up to 120 seconds. + + Expected: success screen. Verify LTV shown is low (safe zone). + expected: > + Borrow completes. LTV in safe zone. + screenshot: true + soft_fail: true + + - name: Borrow > Verify toast + balances + instruction: > + If SKIPPED, mark this SKIPPED too. + + Check toast. Verify Action Center card. + + Verify: + - Active loan shows borrowed amount + - Free balance increased (borrowed funds land in free balance) + - LTV < 80% + + REPORT: "BORROW: X USDC. LTV: Y%. Free: [before] → [after]." + expected: > + Loan active. Free balance increased. LTV safe. + screenshot: true + soft_fail: true + + # ======================================================================== + # PHASE 6: Repay (full repayment) + # ======================================================================== + + - name: Repay > Full repayment + instruction: > + If borrow was SKIPPED, mark this SKIPPED too. + + Look for "Repay" tab or button. Click it. + + Toggle "Full Repayment" or click Max. + Verify free balance can cover the full debt. + + Click submit. Confirm. Sign. Wait up to 120 seconds. + + Expected: success screen. No active loans after. + expected: > + Full repayment completes. No active loans. + screenshot: true + soft_fail: true + + - name: Repay > Verify toast + balances + instruction: > + If SKIPPED, mark this SKIPPED too. + + Check toast. Verify Action Center card. + + Verify: + - No active loans + - LTV: 0% or N/A + - Free balance decreased by repayment amount + + REPORT: "REPAY: Full repayment of X USDC. Loans: 0." + expected: > + Loan cleared. Action Center card present. + screenshot: true + soft_fail: true + + # ======================================================================== + # PHASE 7: Remove Collateral (collateral → free balance) + # ======================================================================== + + - name: Remove Collateral > Execute + instruction: > + If collateral phases were SKIPPED, mark this SKIPPED too. + + Look for "Remove Collateral" option. Click it. + Enter full collateral amount or click Max (no loans, safe + to remove everything). + + Submit. Confirm. Sign. Wait up to 120 seconds. + + After success, verify: + - Collateral: 0 (or reduced) + - Free balance increased + + Check toast + Action Center card. + + REPORT: "REMOVE COLLATERAL: X USDC. Collateral: [before] → [after]. + Free: [before] → [after]." + expected: > + Collateral removed. Free balance restored. + screenshot: true + soft_fail: true + + # ======================================================================== + # PHASE 8: Egress (free balance → wallet) + # ======================================================================== + + - name: Egress > Open modal + instruction: > + On USDC pool page, click "Withdraw from Chainflip" or egress + button. + + Verify: + - Available: current free balance + - Destination address pre-filled with wallet address + expected: > + Egress modal opens with free balance and destination. + screenshot: true + + - name: Egress > Withdraw to wallet + instruction: > + Click Max or enter full free balance amount. + Verify destination is wallet address. + + Click submit. Confirm. Click "Confirm Withdrawal". + + For native wallet: click "Confirm". + Wait up to 300 seconds (egress is an on-chain broadcast from + Chainflip vaults — can take 1-5 minutes). + + Expected: success screen with optional tx reference/link. + expected: > + Egress completes. Transaction reference shown. + screenshot: true + + - name: Egress > Verify toast + Action Center + instruction: > + Wait 10s for toast. Should mention withdrawal from Chainflip. + + If toast visible: + 1. Click toast → Action Center + 2. Verify card: "Chainflip Lending", "Confirmed" + 3. Close drawer + + If no toast: SOFT FAIL, check manually. + expected: > + Toast + Action Center card for egress. + screenshot: true + soft_fail: true + + - name: Egress > Verify final state + instruction: > + Click "Done". Wait 5 seconds. + + Verify: + - Free Balance: ~0 + - Supplied: unchanged or 0 + - Collateral: 0 + + REPORT: "EGRESS: X USDC from State Chain to wallet. + All State Chain balances near zero. Round-trip complete." + expected: > + State Chain balances zeroed. Round-trip complete. + screenshot: true + + # ======================================================================== + # PHASE 9: Final Action Center audit + # ======================================================================== + + - name: Action Center > Full audit + instruction: > + Open Action Center (bell icon in header). + + Count all "Chainflip Lending" cards from this session. + Expected cards (reverse chronological, some may be SKIPPED): + + 1. Egress — Confirmed + 2. Remove Collateral — Confirmed (if available) + 3. Repay — Confirmed (if available) + 4. Borrow — Confirmed (if available) + 5. Add Collateral — Confirmed (if available) + 6. Withdraw from pool — Confirmed (if supply was run) + 7. Supply to pool — Confirmed (if supply was run) + 8. Deposit — Confirmed (if deposit was run) + + For each card verify: + - Type: "Chainflip Lending" + - Status: green "Confirmed" + - Description: domain-appropriate (distinguishable operations) + - Asset icon: USDC + - Timestamp: from this session + + BUG HUNT: + - Missing cards? Duplicates? + - Any stuck in "Pending"? + - Can you distinguish deposit vs supply vs withdraw cards? + + Close Action Center. + + REPORT: "ACTION CENTER AUDIT: X/Y expected cards found. + All statuses correct. Operations distinguishable." + expected: > + All executed operations have Action Center cards. + Types and statuses correct. + screenshot: true + soft_fail: true diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 1524953ad32..6f8e12ac275 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -3227,6 +3227,54 @@ "complete": { "description": "Your reward of %{amountAndSymbol} is complete." } + }, + "chainflipLending": { + "deposit": { + "pending": "Your deposit of %{amountAndSymbol} to Chainflip is being processed.", + "complete": "Your deposit of %{amountAndSymbol} to Chainflip is complete.", + "failed": "Your deposit of %{amountAndSymbol} to Chainflip has failed." + }, + "supply": { + "pending": "Your supply of %{amountAndSymbol} to the lending pool is being processed.", + "complete": "Your supply of %{amountAndSymbol} to the lending pool is complete.", + "failed": "Your supply of %{amountAndSymbol} to the lending pool has failed." + }, + "withdraw": { + "pending": "Your withdrawal of %{amountAndSymbol} from the lending pool is being processed.", + "complete": "Your withdrawal of %{amountAndSymbol} from the lending pool is complete.", + "failed": "Your withdrawal of %{amountAndSymbol} from the lending pool has failed." + }, + "egress": { + "pending": "Your withdrawal of %{amountAndSymbol} from Chainflip is being processed.", + "complete": "Your withdrawal of %{amountAndSymbol} from Chainflip is complete.", + "failed": "Your withdrawal of %{amountAndSymbol} from Chainflip has failed." + }, + "addCollateral": { + "pending": "Your move of %{amountAndSymbol} from your Chainflip free balance to collateral is being processed.", + "complete": "Your move of %{amountAndSymbol} from your Chainflip free balance to collateral is complete.", + "failed": "Your move of %{amountAndSymbol} from your Chainflip free balance to collateral has failed." + }, + "removeCollateral": { + "pending": "Your move of %{amountAndSymbol} from collateral back to your Chainflip free balance is being processed.", + "complete": "Your move of %{amountAndSymbol} from collateral back to your Chainflip free balance is complete.", + "failed": "Your move of %{amountAndSymbol} from collateral back to your Chainflip free balance has failed." + }, + "borrow": { + "pending": "Your borrow of %{amountAndSymbol} to your Chainflip free balance is being processed.", + "complete": "Your borrow of %{amountAndSymbol} to your Chainflip free balance is complete.", + "failed": "Your borrow of %{amountAndSymbol} to your Chainflip free balance has failed." + }, + "repay": { + "pending": "Your repayment of %{amountAndSymbol} from your Chainflip free balance is being processed.", + "complete": "Your repayment of %{amountAndSymbol} from your Chainflip free balance is complete.", + "failed": "Your repayment of %{amountAndSymbol} from your Chainflip free balance has failed." + }, + "details": { + "operation": "Operation", + "amount": "Amount", + "transactionId": "Transaction Id", + "egressTransactionId": "Egress Transaction Id" + } } }, "yieldXYZ": { diff --git a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx index 27b95a02ce5..b494a6d60d6 100644 --- a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx +++ b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx @@ -19,6 +19,7 @@ import { Virtuoso } from 'react-virtuoso' import { useActionCenterContext } from './ActionCenterContext' import { AppUpdateActionCard } from './components/AppUpdateActionCard' import { ArbitrumBridgeWithdrawActionCard } from './components/ArbitrumBridgeWithdrawActionCard' +import { ChainflipLendingActionCard } from './components/ChainflipLendingActionCard' import { EmptyState } from './components/EmptyState' import { GenericTransactionActionCard } from './components/GenericTransactionActionCard' import { LimitOrderActionCard } from './components/LimitOrderActionCard' @@ -146,6 +147,9 @@ export const ActionCenter = memo(() => { case ActionType.ArbitrumBridgeWithdraw: { return } + case ActionType.ChainflipLending: { + return + } default: return null } diff --git a/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx new file mode 100644 index 00000000000..b6222a88c46 --- /dev/null +++ b/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx @@ -0,0 +1,229 @@ +import { Button, ButtonGroup, HStack, Link, Stack, useDisclosure } from '@chakra-ui/react' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { ActionCard } from './ActionCard' +import { ActionStatusIcon } from './ActionStatusIcon' +import { ActionStatusTag } from './ActionStatusTag' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' +import { RawText } from '@/components/Text' +import type { TextPropTypes } from '@/components/Text/Text' +import { Text } from '@/components/Text/Text' +import { middleEllipsis } from '@/lib/utils' +import { formatSmartDate } from '@/lib/utils/time' +import type { ChainflipLendingAction } from '@/state/slices/actionSlice/types' +import { ActionStatus, ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' +import { selectAssetById } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +dayjs.extend(relativeTime) + +type ChainflipLendingActionCardProps = { + action: ChainflipLendingAction +} + +export const ChainflipLendingActionCard = ({ action }: ChainflipLendingActionCardProps) => { + const translate = useTranslate() + const { chainflipLendingMetadata } = action + const operationTestIdSuffix = chainflipLendingMetadata.operationType + + const asset = useAppSelector(state => selectAssetById(state, chainflipLendingMetadata.assetId)) + + const formattedDate = useMemo(() => { + return formatSmartDate(action.updatedAt) + }, [action.updatedAt]) + + const { isOpen, onToggle } = useDisclosure({ + defaultIsOpen: action.status === ActionStatus.Pending, + }) + + const translationComponents = useMemo((): TextPropTypes['components'] | undefined => { + if (!asset) return undefined + + return { + amountAndSymbol: ( + + ), + } + }, [asset, chainflipLendingMetadata.amountCryptoPrecision]) + + const icon = useMemo(() => { + return ( + + + + ) + }, [chainflipLendingMetadata.assetId, action.status]) + + const description = useMemo(() => { + return ( + + ) + }, [ + chainflipLendingMetadata.message, + chainflipLendingMetadata.amountCryptoPrecision, + asset?.symbol, + operationTestIdSuffix, + translationComponents, + ]) + + const footer = useMemo(() => { + return + }, [action.status]) + + const operationLabel = useMemo(() => { + switch (chainflipLendingMetadata.operationType) { + case ChainflipLendingOperationType.Deposit: + return translate('chainflipLending.depositToChainflip') + case ChainflipLendingOperationType.Supply: + return translate('chainflipLending.supply.title') + case ChainflipLendingOperationType.Withdraw: + return translate('common.withdraw') + case ChainflipLendingOperationType.Egress: + return translate('chainflipLending.pool.withdrawFromChainflip') + case ChainflipLendingOperationType.AddCollateral: + return translate('chainflipLending.collateral.add') + case ChainflipLendingOperationType.RemoveCollateral: + return translate('chainflipLending.collateral.remove') + case ChainflipLendingOperationType.Borrow: + return translate('chainflipLending.borrow.title') + case ChainflipLendingOperationType.Repay: + return translate('chainflipLending.repay.title') + default: + return chainflipLendingMetadata.operationType + } + }, [chainflipLendingMetadata.operationType, translate]) + + const egressTxLink = useMemo(() => { + if (!chainflipLendingMetadata.egressTxRef || !asset?.explorerTxLink) return undefined + return `${asset.explorerTxLink}${chainflipLendingMetadata.egressTxRef}` + }, [chainflipLendingMetadata.egressTxRef, asset?.explorerTxLink]) + + const details = useMemo(() => { + return ( + + + + + {translate('actionCenter.chainflipLending.details.operation')} + + + {operationLabel} + + + + + {translate('actionCenter.chainflipLending.details.amount')} + + + + {chainflipLendingMetadata.txHash && ( + + + {translate('actionCenter.chainflipLending.details.transactionId')} + + + {middleEllipsis(chainflipLendingMetadata.txHash)} + + + )} + {chainflipLendingMetadata.egressTxRef && ( + + + {translate('actionCenter.chainflipLending.details.egressTransactionId')} + + + {middleEllipsis(chainflipLendingMetadata.egressTxRef)} + + + )} + + {egressTxLink && ( + + + + )} + + ) + }, [ + asset?.symbol, + chainflipLendingMetadata.amountCryptoPrecision, + chainflipLendingMetadata.egressTxRef, + chainflipLendingMetadata.txHash, + egressTxLink, + operationLabel, + operationTestIdSuffix, + translate, + ]) + + if (!asset) return null + + return ( + + {details} + + ) +} diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/ChainflipLendingNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/ChainflipLendingNotification.tsx new file mode 100644 index 00000000000..c04a6522697 --- /dev/null +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/ChainflipLendingNotification.tsx @@ -0,0 +1,94 @@ +import type { RenderProps } from '@chakra-ui/react/dist/types/toast/toast.types' +import { useMemo } from 'react' + +import { ActionIcon } from '../ActionIcon' + +import { Amount } from '@/components/Amount/Amount' +import { Text } from '@/components/Text' +import type { TextPropTypes } from '@/components/Text/Text' +import { StandardToast } from '@/components/Toast/StandardToast' +import { actionSlice } from '@/state/slices/actionSlice/actionSlice' +import type { ChainflipLendingAction } from '@/state/slices/actionSlice/types' +import { isChainflipLendingAction } from '@/state/slices/actionSlice/types' +import { selectAssetById } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type ChainflipLendingNotificationProps = { + handleClick: () => void + actionId: string +} & RenderProps + +export const ChainflipLendingNotification = ({ + handleClick, + actionId, + onClose, + status, +}: ChainflipLendingNotificationProps) => { + const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) + + const action = useMemo((): ChainflipLendingAction | undefined => { + const maybeAction = actionsById[actionId] + if (!maybeAction || !isChainflipLendingAction(maybeAction)) return undefined + return maybeAction + }, [actionsById, actionId]) + + const asset = useAppSelector(state => + selectAssetById(state, action?.chainflipLendingMetadata?.assetId ?? ''), + ) + + const icon = useMemo(() => { + if (!action || !asset) return undefined + return + }, [action, asset]) + + const translationComponents = useMemo((): TextPropTypes['components'] | undefined => { + if (!action || !asset) return undefined + + return { + amountAndSymbol: ( + + ), + } + }, [action, asset]) + + const title = useMemo(() => { + if (!action || !translationComponents) return undefined + + return ( + + ) + }, [action, translationComponents, asset?.symbol]) + + if (!action || !icon || !title) return null + + const toastStatus = status === 'loading' ? 'info' : status + + return ( + + ) +} diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx index 9d2dc7739b2..9f9a287a7f8 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx @@ -7,6 +7,7 @@ import { useTranslate } from 'react-polyglot' import { BorrowMachineCtx } from './BorrowMachineContext' import { BorrowStepper } from './BorrowStepper' +import { useBorrowActionCenter } from './hooks/useBorrowActionCenter' import { useBorrowConfirmation } from './hooks/useBorrowConfirmation' import { useBorrowSign } from './hooks/useBorrowSign' @@ -49,6 +50,7 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { useBorrowSign() useBorrowConfirmation() + useBorrowActionCenter() const projectedLtvPercent = useMemo(() => (projectedLtvBps / 100).toFixed(1), [projectedLtvBps]) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx index a44fafde951..72c8687508b 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx @@ -7,6 +7,7 @@ import { useTranslate } from 'react-polyglot' import { CollateralMachineCtx } from './CollateralMachineContext' import { CollateralStepper } from './CollateralStepper' +import { useCollateralActionCenter } from './hooks/useCollateralActionCenter' import { useCollateralConfirmation } from './hooks/useCollateralConfirmation' import { useCollateralSign } from './hooks/useCollateralSign' @@ -46,6 +47,7 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { useCollateralSign() useCollateralConfirmation() + useCollateralActionCenter() const isAddMode = mode === 'add' diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx index 20893732ac5..f1324e525dc 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useRepayActionCenter } from './hooks/useRepayActionCenter' import { useRepayConfirmation } from './hooks/useRepayConfirmation' import { useRepaySign } from './hooks/useRepaySign' import { RepayMachineCtx } from './RepayMachineContext' @@ -46,6 +47,7 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { useRepaySign() useRepayConfirmation() + useRepayActionCenter() const handleConfirm = useCallback(() => { actorRef.send({ type: 'CONFIRM' }) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useBorrowActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useBorrowActionCenter.tsx new file mode 100644 index 00000000000..8f852c08122 --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useBorrowActionCenter.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' + +import { BorrowMachineCtx } from '../BorrowMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useBorrowActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSigning = BorrowMachineCtx.useSelector(s => s.matches('signing')) + const isSuccess = BorrowMachineCtx.useSelector(s => s.matches('success')) + const isError = BorrowMachineCtx.useSelector(s => s.matches('error')) + const assetId = BorrowMachineCtx.useSelector(s => s.context.assetId) + const borrowAmountCryptoPrecision = BorrowMachineCtx.useSelector( + s => s.context.borrowAmountCryptoPrecision, + ) + const txHash = BorrowMachineCtx.useSelector(s => s.context.txHash) + + useEffect(() => { + if (isSigning && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: ChainflipLendingOperationType.Borrow, + amountCryptoPrecision: borrowAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSigning, accountId, createAction, borrowAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current, txHash ?? undefined) + actionIdRef.current = null + } + }, [isSuccess, completeAction, txHash]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useCollateralActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useCollateralActionCenter.tsx new file mode 100644 index 00000000000..f04620b0e6e --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useCollateralActionCenter.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react' + +import { CollateralMachineCtx } from '../CollateralMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useCollateralActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSigning = CollateralMachineCtx.useSelector(s => s.matches('signing')) + const isSuccess = CollateralMachineCtx.useSelector(s => s.matches('success')) + const isError = CollateralMachineCtx.useSelector(s => s.matches('error')) + const mode = CollateralMachineCtx.useSelector(s => s.context.mode) + const assetId = CollateralMachineCtx.useSelector(s => s.context.assetId) + const collateralAmountCryptoPrecision = CollateralMachineCtx.useSelector( + s => s.context.collateralAmountCryptoPrecision, + ) + const txHash = CollateralMachineCtx.useSelector(s => s.context.txHash) + + useEffect(() => { + if (isSigning && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: + mode === 'add' + ? ChainflipLendingOperationType.AddCollateral + : ChainflipLendingOperationType.RemoveCollateral, + amountCryptoPrecision: collateralAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSigning, accountId, createAction, mode, collateralAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current, txHash ?? undefined) + actionIdRef.current = null + } + }, [isSuccess, completeAction, txHash]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useRepayActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useRepayActionCenter.tsx new file mode 100644 index 00000000000..f86f80d3a13 --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Borrow/hooks/useRepayActionCenter.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' + +import { RepayMachineCtx } from '../RepayMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useRepayActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSigning = RepayMachineCtx.useSelector(s => s.matches('signing')) + const isSuccess = RepayMachineCtx.useSelector(s => s.matches('success')) + const isError = RepayMachineCtx.useSelector(s => s.matches('error')) + const assetId = RepayMachineCtx.useSelector(s => s.context.assetId) + const repayAmountCryptoPrecision = RepayMachineCtx.useSelector( + s => s.context.repayAmountCryptoPrecision, + ) + const txHash = RepayMachineCtx.useSelector(s => s.context.txHash) + + useEffect(() => { + if (isSigning && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: ChainflipLendingOperationType.Repay, + amountCryptoPrecision: repayAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSigning, accountId, createAction, repayAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current, txHash ?? undefined) + actionIdRef.current = null + } + }, [isSuccess, completeAction, txHash]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx index d473339dc38..587830a6abd 100644 --- a/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx @@ -9,6 +9,7 @@ import { useTranslate } from 'react-polyglot' import { DepositMachineCtx } from './DepositMachineContext' import { DepositStepper } from './DepositStepper' +import { useDepositActionCenter } from './hooks/useDepositActionCenter' import { useDepositApproval } from './hooks/useDepositApproval' import { useDepositChannel } from './hooks/useDepositChannel' import { useDepositConfirmation } from './hooks/useDepositConfirmation' @@ -73,6 +74,7 @@ export const DepositConfirm = memo(({ assetId }: DepositConfirmProps) => { useDepositChannel() useDepositSend() useDepositConfirmation() + useDepositActionCenter() const poolChainId = useMemo(() => fromAssetId(assetId).chainId, [assetId]) diff --git a/src/pages/ChainflipLending/Pool/components/Deposit/hooks/useDepositActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Deposit/hooks/useDepositActionCenter.tsx new file mode 100644 index 00000000000..53e9ebcdcb3 --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Deposit/hooks/useDepositActionCenter.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef } from 'react' + +import { DepositMachineCtx } from '../DepositMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useDepositActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSendingDeposit = DepositMachineCtx.useSelector(s => s.matches('sending_deposit')) + const isSuccess = DepositMachineCtx.useSelector(s => s.matches('success')) + const isError = DepositMachineCtx.useSelector(s => s.matches('error')) + const assetId = DepositMachineCtx.useSelector(s => s.context.assetId) + const depositAmountCryptoPrecision = DepositMachineCtx.useSelector( + s => s.context.depositAmountCryptoPrecision, + ) + + useEffect(() => { + if (isSendingDeposit && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: ChainflipLendingOperationType.Deposit, + amountCryptoPrecision: depositAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSendingDeposit, accountId, createAction, depositAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isSuccess, completeAction]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx index 6907a9737bd..193013d4152 100644 --- a/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx @@ -7,6 +7,7 @@ import { useTranslate } from 'react-polyglot' import { EgressMachineCtx } from './EgressMachineContext' import { EgressStepper } from './EgressStepper' +import { useEgressActionCenter } from './hooks/useEgressActionCenter' import { useEgressConfirmation } from './hooks/useEgressConfirmation' import { useEgressSign } from './hooks/useEgressSign' @@ -55,6 +56,7 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { useEgressSign() useEgressConfirmation() + useEgressActionCenter() const handleConfirm = useCallback(() => { actorRef.send({ type: 'CONFIRM' }) diff --git a/src/pages/ChainflipLending/Pool/components/Egress/hooks/useEgressActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Egress/hooks/useEgressActionCenter.tsx new file mode 100644 index 00000000000..e97cd72545f --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Egress/hooks/useEgressActionCenter.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' + +import { EgressMachineCtx } from '../EgressMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useEgressActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSigning = EgressMachineCtx.useSelector(s => s.matches('signing')) + const isSuccess = EgressMachineCtx.useSelector(s => s.matches('success')) + const isError = EgressMachineCtx.useSelector(s => s.matches('error')) + const assetId = EgressMachineCtx.useSelector(s => s.context.assetId) + const egressAmountCryptoPrecision = EgressMachineCtx.useSelector( + s => s.context.egressAmountCryptoPrecision, + ) + const egressTxRef = EgressMachineCtx.useSelector(s => s.context.egressTxRef) + + useEffect(() => { + if (isSigning && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: ChainflipLendingOperationType.Egress, + amountCryptoPrecision: egressAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSigning, accountId, createAction, egressAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current, undefined, egressTxRef ?? undefined) + actionIdRef.current = null + } + }, [isSuccess, completeAction, egressTxRef]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx index 4796830c6d6..9ff491a8aa3 100644 --- a/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useSupplyActionCenter } from './hooks/useSupplyActionCenter' import { useSupplyConfirmation } from './hooks/useSupplyConfirmation' import { useSupplySign } from './hooks/useSupplySign' import { SupplyMachineCtx } from './SupplyMachineContext' @@ -47,6 +48,7 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { useSupplySign() useSupplyConfirmation() + useSupplyActionCenter() const handleConfirm = useCallback(() => { actorRef.send({ type: 'CONFIRM' }) diff --git a/src/pages/ChainflipLending/Pool/components/Supply/hooks/useSupplyActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Supply/hooks/useSupplyActionCenter.tsx new file mode 100644 index 00000000000..4ab65715d0b --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Supply/hooks/useSupplyActionCenter.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' + +import { SupplyMachineCtx } from '../SupplyMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useSupplyActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSigning = SupplyMachineCtx.useSelector(s => s.matches('signing')) + const isSuccess = SupplyMachineCtx.useSelector(s => s.matches('success')) + const isError = SupplyMachineCtx.useSelector(s => s.matches('error')) + const assetId = SupplyMachineCtx.useSelector(s => s.context.assetId) + const supplyAmountCryptoPrecision = SupplyMachineCtx.useSelector( + s => s.context.supplyAmountCryptoPrecision, + ) + const txHash = SupplyMachineCtx.useSelector(s => s.context.txHash) + + useEffect(() => { + if (isSigning && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: ChainflipLendingOperationType.Supply, + amountCryptoPrecision: supplyAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSigning, accountId, createAction, supplyAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current, txHash ?? undefined) + actionIdRef.current = null + } + }, [isSuccess, completeAction, txHash]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx index b93d4e1db59..7bf086274f4 100644 --- a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useWithdrawActionCenter } from './hooks/useWithdrawActionCenter' import { useWithdrawConfirmation } from './hooks/useWithdrawConfirmation' import { useWithdrawSign } from './hooks/useWithdrawSign' import { WithdrawMachineCtx } from './WithdrawMachineContext' @@ -47,6 +48,7 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { useWithdrawSign() useWithdrawConfirmation() + useWithdrawActionCenter() const handleConfirm = useCallback(() => { actorRef.send({ type: 'CONFIRM' }) diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/hooks/useWithdrawActionCenter.tsx b/src/pages/ChainflipLending/Pool/components/Withdraw/hooks/useWithdrawActionCenter.tsx new file mode 100644 index 00000000000..b4a8aacb393 --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/hooks/useWithdrawActionCenter.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' + +import { WithdrawMachineCtx } from '../WithdrawMachineContext' + +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingAction } from '@/pages/ChainflipLending/hooks/useChainflipLendingAction' +import { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' + +export const useWithdrawActionCenter = () => { + const { accountId } = useChainflipLendingAccount() + const { createAction, completeAction, failAction } = useChainflipLendingAction() + const actionIdRef = useRef(null) + + const isSigning = WithdrawMachineCtx.useSelector(s => s.matches('signing')) + const isSuccess = WithdrawMachineCtx.useSelector(s => s.matches('success')) + const isError = WithdrawMachineCtx.useSelector(s => s.matches('error')) + const assetId = WithdrawMachineCtx.useSelector(s => s.context.assetId) + const withdrawAmountCryptoPrecision = WithdrawMachineCtx.useSelector( + s => s.context.withdrawAmountCryptoPrecision, + ) + const txHash = WithdrawMachineCtx.useSelector(s => s.context.txHash) + + useEffect(() => { + if (isSigning && !actionIdRef.current && accountId) { + actionIdRef.current = createAction({ + operationType: ChainflipLendingOperationType.Withdraw, + amountCryptoPrecision: withdrawAmountCryptoPrecision, + assetId, + accountId, + }) + } + }, [isSigning, accountId, createAction, withdrawAmountCryptoPrecision, assetId]) + + useEffect(() => { + if (isSuccess && actionIdRef.current) { + completeAction(actionIdRef.current, txHash ?? undefined) + actionIdRef.current = null + } + }, [isSuccess, completeAction, txHash]) + + useEffect(() => { + if (isError && actionIdRef.current) { + failAction(actionIdRef.current) + actionIdRef.current = null + } + }, [isError, failAction]) +} diff --git a/src/pages/ChainflipLending/hooks/useChainflipLendingAction.tsx b/src/pages/ChainflipLending/hooks/useChainflipLendingAction.tsx new file mode 100644 index 00000000000..bd70202fdf0 --- /dev/null +++ b/src/pages/ChainflipLending/hooks/useChainflipLendingAction.tsx @@ -0,0 +1,159 @@ +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { uuidv4 } from '@walletconnect/utils' +import { useCallback } from 'react' + +import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' +import { ChainflipLendingNotification } from '@/components/Layout/Header/ActionCenter/components/Notifications/ChainflipLendingNotification' +import { useNotificationToast } from '@/hooks/useNotificationToast' +import { vibrate } from '@/lib/vibrate' +import { actionSlice } from '@/state/slices/actionSlice/actionSlice' +import type { ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' +import { + ActionStatus, + ActionType, + isChainflipLendingAction, +} from '@/state/slices/actionSlice/types' +import { store, useAppDispatch } from '@/state/store' + +type CreateLendingActionArgs = { + operationType: ChainflipLendingOperationType + amountCryptoPrecision: string + assetId: AssetId + accountId: AccountId +} + +export const useChainflipLendingAction = () => { + const dispatch = useAppDispatch() + const toast = useNotificationToast() + const { openActionCenter, isDrawerOpen } = useActionCenterContext() + + const createAction = useCallback( + (args: CreateLendingActionArgs) => { + const { operationType, amountCryptoPrecision, assetId, accountId } = args + const id = uuidv4() + const now = Date.now() + + const messageKey = `actionCenter.chainflipLending.${operationType}.pending` + + dispatch( + actionSlice.actions.upsertAction({ + id, + type: ActionType.ChainflipLending, + status: ActionStatus.Pending, + createdAt: now, + updatedAt: now, + chainflipLendingMetadata: { + operationType, + amountCryptoPrecision, + assetId, + accountId, + message: messageKey, + }, + }), + ) + + return id + }, + [dispatch], + ) + + const completeAction = useCallback( + (actionId: string, txHash?: string, egressTxRef?: string) => { + vibrate('heavy') + + const state = store.getState() + const action = state.action.byId[actionId] + if (!action || !isChainflipLendingAction(action)) return + + const { operationType } = action.chainflipLendingMetadata + const messageKey = `actionCenter.chainflipLending.${operationType}.complete` + + dispatch( + actionSlice.actions.upsertAction({ + ...action, + status: ActionStatus.Complete, + updatedAt: Date.now(), + chainflipLendingMetadata: { + ...action.chainflipLendingMetadata, + message: messageKey, + txHash, + egressTxRef, + }, + }), + ) + + if (!toast.isActive(actionId)) { + toast({ + id: actionId, + duration: isDrawerOpen ? 5000 : null, + status: 'success', + render: ({ onClose, ...props }) => { + const handleClick = () => { + onClose() + openActionCenter() + } + + return ( + + ) + }, + }) + } + }, + [dispatch, toast, isDrawerOpen, openActionCenter], + ) + + const failAction = useCallback( + (actionId: string) => { + const state = store.getState() + const action = state.action.byId[actionId] + if (!action || !isChainflipLendingAction(action)) return + + const { operationType } = action.chainflipLendingMetadata + const messageKey = `actionCenter.chainflipLending.${operationType}.failed` + + dispatch( + actionSlice.actions.upsertAction({ + ...action, + status: ActionStatus.Failed, + updatedAt: Date.now(), + chainflipLendingMetadata: { + ...action.chainflipLendingMetadata, + message: messageKey, + }, + }), + ) + + if (!toast.isActive(actionId)) { + toast({ + id: actionId, + duration: 5000, + status: 'error', + render: ({ onClose, ...props }) => { + const handleClick = () => { + onClose() + openActionCenter() + } + + return ( + + ) + }, + }) + } + }, + [dispatch, toast, openActionCenter], + ) + + return { createAction, completeAction, failAction } +} diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 7eb2c19a525..1636f8db5b1 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -5,6 +5,7 @@ import { selectEnabledWalletAccountIds } from '../common-selectors' import { swapSlice } from '../swapSlice/swapSlice' import { actionSlice } from './actionSlice' import type { + ChainflipLendingAction, GenericTransactionAction, LimitOrderAction, RfoxClaimAction, @@ -15,6 +16,7 @@ import { ActionType, GenericTransactionDisplayType, isArbitrumBridgeWithdrawAction, + isChainflipLendingAction, isGenericTransactionAction, isLimitOrderAction, isPendingSendAction, @@ -80,6 +82,10 @@ export const selectWalletActions = createDeepEqualOutputSelector( return enabledWalletAccountIds.includes(stakingAccountId) } + if (isChainflipLendingAction(action)) { + return enabledWalletAccountIds.includes(action.chainflipLendingMetadata.accountId) + } + return action }) }, @@ -317,3 +323,13 @@ export const selectYieldActionsByTxHash = createDeepEqualOutputSelector( return result }, ) + +export const selectPendingChainflipLendingActions = createDeepEqualOutputSelector( + selectWalletActions, + actions => { + return actions.filter( + (action): action is ChainflipLendingAction => + isChainflipLendingAction(action) && action.status === ActionStatus.Pending, + ) + }, +) diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index 58707a29b56..d8362ea0769 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -26,6 +26,7 @@ export enum ActionType { ChangeAddress = 'ChangeAddress', RewardDistribution = 'RewardDistribution', ArbitrumBridgeWithdraw = 'ArbitrumBridgeWithdraw', + ChainflipLending = 'Chainflip Lending', } export enum ActionStatus { @@ -85,6 +86,27 @@ type ActionArbitrumBridgeWithdrawMetadata = { claimDetails?: ClaimDetails } +export enum ChainflipLendingOperationType { + Deposit = 'deposit', + Supply = 'supply', + Withdraw = 'withdraw', + Egress = 'egress', + AddCollateral = 'addCollateral', + RemoveCollateral = 'removeCollateral', + Borrow = 'borrow', + Repay = 'repay', +} + +type ActionChainflipLendingMetadata = { + operationType: ChainflipLendingOperationType + amountCryptoPrecision: string + assetId: AssetId + accountId: AccountId + message: string + txHash?: string + egressTxRef?: string +} + export enum GenericTransactionDisplayType { TCY = 'TCY', RFOX = 'rFOX', @@ -191,6 +213,11 @@ export type ArbitrumBridgeWithdrawAction = BaseAction & { arbitrumBridgeMetadata: ActionArbitrumBridgeWithdrawMetadata } +export type ChainflipLendingAction = BaseAction & { + type: ActionType.ChainflipLending + chainflipLendingMetadata: ActionChainflipLendingMetadata +} + export type Action = | SwapAction | LimitOrderAction @@ -200,6 +227,7 @@ export type Action = | TcyClaimAction | RewardDistributionAction | ArbitrumBridgeWithdrawAction + | ChainflipLendingAction export type ActionState = { byId: Record @@ -259,3 +287,7 @@ export const isArbitrumBridgeWithdrawAction = ( ): action is ArbitrumBridgeWithdrawAction => { return Boolean(action.type === ActionType.ArbitrumBridgeWithdraw && action.arbitrumBridgeMetadata) } + +export const isChainflipLendingAction = (action: Action): action is ChainflipLendingAction => { + return Boolean(action.type === ActionType.ChainflipLending && action.chainflipLendingMetadata) +} From a775d050254c9b9f761b9611a3cd1917a5e5d4b4 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 11:20:47 +0100 Subject: [PATCH 10/31] fix: unrug chainflip ltv gauge (#12087) From 14b724cd964349d4b5cf44b16478c36c4c788fe9 Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:51:24 +0100 Subject: [PATCH 11/31] fix: disable rfox claim button when no claimable unstaking requests (#12071) --- src/pages/Fox/components/RFOXSection.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/pages/Fox/components/RFOXSection.tsx b/src/pages/Fox/components/RFOXSection.tsx index 55717f3faaf..25fdf03c106 100644 --- a/src/pages/Fox/components/RFOXSection.tsx +++ b/src/pages/Fox/components/RFOXSection.tsx @@ -49,6 +49,7 @@ import { selectStakingBalance } from '@/pages/RFOX/helpers' import { useCurrentApyQuery } from '@/pages/RFOX/hooks/useCurrentApyQuery' import { useCurrentEpochMetadataQuery } from '@/pages/RFOX/hooks/useCurrentEpochMetadataQuery' import { useCurrentEpochRewardsQuery } from '@/pages/RFOX/hooks/useCurrentEpochRewardsQuery' +import { useGetUnstakingRequestsQuery } from '@/pages/RFOX/hooks/useGetUnstakingRequestsQuery' import type { UnstakingRequest } from '@/pages/RFOX/hooks/useGetUnstakingRequestsQuery/utils' import { useLifetimeRewardsUserCurrencyQuery } from '@/pages/RFOX/hooks/useLifetimeRewardsQuery' import { useRFOXContext } from '@/pages/RFOX/hooks/useRfoxContext' @@ -186,6 +187,19 @@ export const RFOXSection = () => { return matchingAccountId }, [accountIdsByAccountNumberAndChainId, assetAccountNumber, stakingAssetId]) + const allUnstakingRequestsQuery = useGetUnstakingRequestsQuery() + + const hasClaimableRequests = useMemo(() => { + const accountRequests = allUnstakingRequestsQuery.data?.byAccountId[stakingAssetAccountId ?? ''] + if (!accountRequests?.length) return false + + return accountRequests.some(request => { + const currentTimestampMs = Date.now() + const unstakingTimestampMs = Number(request.cooldownExpiry) * 1000 + return currentTimestampMs >= unstakingTimestampMs + }) + }, [allUnstakingRequestsQuery.data?.byAccountId, stakingAssetAccountId]) + useEffect(() => { if (selectedUnstakingRequest) return @@ -320,12 +334,20 @@ export const RFOXSection = () => { onClick={handleClaimClick} colorScheme='green' flex='1 1 auto' + isDisabled={!hasClaimableRequests} > {translate('defi.claim')} ) - }, [handleStakeClick, handleUnstakeClick, handleClaimClick, translate, stakingAssetId]) + }, [ + handleStakeClick, + handleUnstakeClick, + handleClaimClick, + translate, + stakingAssetId, + hasClaimableRequests, + ]) if (!(stakingAsset && usdcAsset)) return null From 29959474ebc516778f2ba1a54d1c25dd4a05e73d Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 12:16:32 +0100 Subject: [PATCH 12/31] feat: gate chainflip lending actions by safe mode (#12088) --- src/assets/translations/en/main.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 6f8e12ac275..d828ef5e7f2 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2449,6 +2449,7 @@ "totalBorrowed": "Total Borrowed", "totalBorrowedTooltip": "Total value of outstanding borrows across all Chainflip lending pools.", "pool": { + "actionPaused": "This action is temporarily paused on Chainflip", "depositFirst": "Deposit funds to Chainflip first", "noFreeBalance": "No free balance available", "noSupplyPosition": "No supply position to withdraw", From a5268032cf50bfbef5f107479cb146fe2fd581d4 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 12:21:19 +0100 Subject: [PATCH 13/31] feat: add reliability checklist to qabot skill (#12089) --- .claude/skills/qabot/SKILL.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.claude/skills/qabot/SKILL.md b/.claude/skills/qabot/SKILL.md index 36c8b00b570..d9c8b23fea8 100644 --- a/.claude/skills/qabot/SKILL.md +++ b/.claude/skills/qabot/SKILL.md @@ -148,6 +148,19 @@ The native wallet requires a password on each session start. The wallet-health f `eval "$(cat /tmp/click-next.js)"` 7. Wait 8+ seconds for external origins to fully hydrate +### PR Review Reliability Checklist (localhost) + +When using qabot for PR review validation on localhost: + +1. Follow PR `Testing` steps verbatim before adding extra assertions. +2. Do a manual-first pass with `agent-browser` in the same live session: + - reach exact page/state + - confirm account/wallet assumptions + - validate selectors and click-path before reporting +3. Keep wallet setup as preflight only (never as reported qabot steps), but ensure required preconditions are visible before step 1 (e.g. `Send` button). +4. Only after manual flow is stable, create/report qabot run steps. +5. If automation friction is selector-related, add/ask for precise `data-testid` at the failing UI control. + ### Tips #### JS Eval & Smart Quotes (CRITICAL) From 89c135a1c9ab475ae789562092612b994fde98c6 Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 13:24:10 +0100 Subject: [PATCH 14/31] fix: ledger yield staking blind signing toast and transaction underpriced (#11949) --- src/lib/yieldxyz/executeTransaction.ts | 7 +++-- .../Yields/hooks/useYieldTransactionFlow.ts | 31 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/lib/yieldxyz/executeTransaction.ts b/src/lib/yieldxyz/executeTransaction.ts index 2e21d55baaa..3c4e3f0803b 100644 --- a/src/lib/yieldxyz/executeTransaction.ts +++ b/src/lib/yieldxyz/executeTransaction.ts @@ -5,6 +5,7 @@ import { CHAIN_NAMESPACE, fromChainId } from '@shapeshiftoss/caip' import type { SignTx } from '@shapeshiftoss/chain-adapters' import { CONTRACT_INTERACTION, toAddressNList } from '@shapeshiftoss/chain-adapters' import type { HDWallet } from '@shapeshiftoss/hdwallet-core' +import { supportsETH } from '@shapeshiftoss/hdwallet-core' import type { EvmChainId } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' import { @@ -182,8 +183,10 @@ const executeEvmTransaction = async ({ addressNList, } + const walletSupportsEIP1559 = supportsETH(wallet) && (await wallet.ethSupportsEIP1559()) + const txToSign: SignTx = - parsed.maxFeePerGas || parsed.maxPriorityFeePerGas + (parsed.maxFeePerGas || parsed.maxPriorityFeePerGas) && walletSupportsEIP1559 ? { ...baseTxToSign, maxFeePerGas: toHexOrDefault(parsed.maxFeePerGas, '0x0'), @@ -191,7 +194,7 @@ const executeEvmTransaction = async ({ } : { ...baseTxToSign, - gasPrice: toHexOrDefault(parsed.gasPrice ?? '0', '0x0'), + gasPrice: toHexOrDefault(parsed.gasPrice ?? parsed.maxFeePerGas ?? '0', '0x0'), } const txHash = await evmSignAndBroadcast({ diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 36e312a0322..53321dfdd46 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -8,6 +8,7 @@ import { fromChainId, usdtAssetId, } from '@shapeshiftoss/caip' +import { ChainAdapterError } from '@shapeshiftoss/chain-adapters' import { assertGetViemClient } from '@shapeshiftoss/contracts' import { BigAmount } from '@shapeshiftoss/utils' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -556,12 +557,15 @@ export const useYieldTransactionFlow = ({ setActiveStepIndex(1) } catch (error) { console.error('Reset allowance failed:', error) + const description = + error instanceof ChainAdapterError + ? translate(error.metadata.translation, error.metadata.options) + : error instanceof Error + ? error.message + : translate('yieldXYZ.errors.transactionFailedDescription') toast({ title: translate('yieldXYZ.errors.transactionFailedTitle'), - description: - error instanceof Error - ? error.message - : translate('yieldXYZ.errors.transactionFailedDescription'), + description, status: 'error', duration: 5000, isClosable: true, @@ -757,10 +761,20 @@ export const useYieldTransactionFlow = ({ } } catch (error) { console.error('Transaction execution failed:', error) - showErrorToast( - 'yieldXYZ.errors.transactionFailedTitle', - 'yieldXYZ.errors.transactionFailedDescription', - ) + if (error instanceof ChainAdapterError) { + toast({ + title: translate('yieldXYZ.errors.transactionFailedTitle'), + description: translate(error.metadata.translation, error.metadata.options), + status: 'error', + duration: 5000, + isClosable: true, + }) + } else { + showErrorToast( + 'yieldXYZ.errors.transactionFailedTitle', + 'yieldXYZ.errors.transactionFailedDescription', + ) + } updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) } finally { setIsSubmitting(false) @@ -776,6 +790,7 @@ export const useYieldTransactionFlow = ({ yieldItem, action, amount, + toast, translate, updateStepStatus, buildCosmosStakeArgs, From ef526e6eb291be9686e1c1a94a0befc66fd1815f Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 13:47:43 +0100 Subject: [PATCH 15/31] feat: opt-in rbf for bitcoin transactions (#11883) --- packages/chain-adapters/src/utxo/UtxoBaseAdapter.ts | 1 + .../src/utxo/bitcoin/BitcoinChainAdapter.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/chain-adapters/src/utxo/UtxoBaseAdapter.ts b/packages/chain-adapters/src/utxo/UtxoBaseAdapter.ts index e5dd6a3dc47..b1ebd972895 100644 --- a/packages/chain-adapters/src/utxo/UtxoBaseAdapter.ts +++ b/packages/chain-adapters/src/utxo/UtxoBaseAdapter.ts @@ -377,6 +377,7 @@ export abstract class UtxoBaseAdapter implements IChainAd vout: input.vout, txid: input.txid, hex: data.hex, + ...(this.chainId === KnownChainIds.BitcoinMainnet && { sequence: 0xfffffffd }), // For Zcash, we need to pass the blockHeight and txid of each input transaction // so Ledger can add them to the PSBT and determine the correct consensus branch ID. // Only pass blockHeight if it's a valid positive number (mempool txs have blockHeight: -1) diff --git a/packages/chain-adapters/src/utxo/bitcoin/BitcoinChainAdapter.test.ts b/packages/chain-adapters/src/utxo/bitcoin/BitcoinChainAdapter.test.ts index 6372b1fb1c6..11a915fc86b 100644 --- a/packages/chain-adapters/src/utxo/bitcoin/BitcoinChainAdapter.test.ts +++ b/packages/chain-adapters/src/utxo/bitcoin/BitcoinChainAdapter.test.ts @@ -1122,6 +1122,7 @@ describe('BitcoinChainAdapter', () => { vout: 0, txid: 'adb979b44c86393236e307c45f9578d9bd064134a2779b4286c158c51ad4ab05', hex: '010000000180457afc57604fed35cc8cee29e602432c87125b9cabbcc8fc407749fe0fabfe010000006b483045022100cd627a0577d35454ced7f0a6ef8a3d3cf11c0f8696bda18062025478e0fc866002206c8ac559dc6bd851bdf00e33c1602fcaeee9d16b35d21b548529825f12dfe5ad0121027751a74f251ba2657ec2a2f374ce7d5ba1548359749823a59314c54a0670c126ffffffff02d97c0000000000001600140c0585f37ff3f9f127c9788941d6082cf7aa012173df0000000000001976a914b22138dfe140e4611b98bdb728eed04beed754c488ac00000000', + sequence: 0xfffffffd, }, ], opReturnData: undefined, @@ -1181,7 +1182,7 @@ describe('BitcoinChainAdapter', () => { }) expect(signedTx).toEqual( - '0100000000010105abd41ac558c186429b77a2344106bdd978955fc407e3363239864cb479b9ad0000000000ffffffff02900100000000000016001408450440a15ea38314c52d5c9ae6201857d7cf7a677a000000000000160014bf44db911ae5acc9cffcc1bbb9622ddda4a1112b024730440220106d6510888c70719b98069ccfa9dc92db248c1f5b7572d5cf86f3db1d371bf40220118ca57a08ed36f94772a5fbd2491a713fcb250a5ccb5e498ba70de8653763ff0121029dc27a53da073b1fea5601cf370d02d3b33cf572156c3a6df9d5c03c5dbcdcd700000000', + '0100000000010105abd41ac558c186429b77a2344106bdd978955fc407e3363239864cb479b9ad0000000000fdffffff02900100000000000016001408450440a15ea38314c52d5c9ae6201857d7cf7a677a000000000000160014bf44db911ae5acc9cffcc1bbb9622ddda4a1112b024730440220261bd026ab75ed19ee9b537204c38953593d37b1f1819fdcedfc9e494ae8503902204f38ac8cbf3145e83bc0578866fd4508fcc97bb57d70b6688253558639a8a4a50121029dc27a53da073b1fea5601cf370d02d3b33cf572156c3a6df9d5c03c5dbcdcd700000000', ) }) }) From 522eb9c93bc686ef8e8ddf664b80476de2687a1d Mon Sep 17 00:00:00 2001 From: gomes-bot Date: Wed, 4 Mar 2026 14:37:41 +0100 Subject: [PATCH 16/31] feat: qabot exploration mode, no-go zones, trade data-testids (#12062) --- e2e/fixtures/swap-exploration.yaml | 134 ++++++++++++++++++ e2e/fixtures/trade-exploration.yaml | 80 +++++++++++ .../BackupPassphraseInfo.tsx | 2 +- .../BackupPassphraseTest.tsx | 2 +- .../CreateWallet/CreateBackupConfirm.tsx | 2 +- .../routes/ImportWallet/ImportSeedPhrase.tsx | 6 +- .../routes/ManualBackup/ManualBackup.tsx | 8 +- .../SharedTradeInput/SharedTradeInputBody.tsx | 5 +- .../SharedTradeInputFooter.tsx | 6 +- .../components/TradeAmountInput.tsx | 8 ++ .../components/TradeInput/TradeInput.tsx | 3 +- .../TradeInput/components/SellAssetInput.tsx | 2 + 12 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 e2e/fixtures/swap-exploration.yaml create mode 100644 e2e/fixtures/trade-exploration.yaml diff --git a/e2e/fixtures/swap-exploration.yaml b/e2e/fixtures/swap-exploration.yaml new file mode 100644 index 00000000000..f5939b91943 --- /dev/null +++ b/e2e/fixtures/swap-exploration.yaml @@ -0,0 +1,134 @@ +name: Cross-Chain Swap Exploration +description: > + Autonomous exploration of swap flows across all first-class EVM chains, + Solana, Cosmos Hub (ATOM), THORChain (RUNE), and Mayachain (CACAO). + + The agent picks random swap pairs across any combination of chains and + directions - same-chain swaps, cross-chain swaps, any asset to any asset. + The goal is to discover broken quotes, failed broadcasts, UI glitches, + and edge cases that scripted fixtures miss. + + IMPORTANT FINANCIAL CONSTRAINTS: + - Maximum $10 per individual swap (use fiat mode, never exceed) + - Maximum $5 CUMULATIVE downside across ALL swaps in the session + (downside = fees + gas + price impact + slippage losses) + - Track running downside: after each swap, compare USD value sent vs received + - If cumulative downside approaches $4, reduce swap sizes to $1-2 + - If cumulative downside hits $5, STOP executing swaps (can still explore UI) + - Prefer small swaps ($1-3) to minimize downside per trade + + CHAIN COVERAGE: + First-class EVM chains: Ethereum (ETH), Avalanche (AVAX), Base (ETH), + Optimism (ETH), Arbitrum (ETH), BNB Smart Chain (BNB), Polygon (POL), + Gnosis (xDAI) + Non-EVM: Solana (SOL), Cosmos Hub (ATOM), THORChain (RUNE), Mayachain (CACAO) + + SWAP COMBINATIONS TO EXPLORE: + - Same-chain: ETH -> USDC on Ethereum, AVAX -> token on Avalanche, etc. + - Cross-chain EVM: ETH (Ethereum) -> ETH (Base), BNB -> AVAX, etc. + - Cross-ecosystem: ETH -> SOL, BTC -> ATOM, RUNE -> ETH, CACAO -> AVAX + - Exotic pairs: ATOM -> SOL, RUNE -> CACAO, xDAI -> POL + - The agent should try diverse combinations, not just obvious ones + + EXECUTION: + - Navigate via hash route: eval "window.location.hash = '/trade'" + - Use data-testid selectors for asset pickers and trade buttons + - Always check balance before swapping (don't swap more than you have) + - Preview every trade before confirming (check the rate/impact) + - If a quote shows >5% price impact, report as finding but don't execute + - If a swap takes >120s, report as finding (slow swap) + - Track which pairs worked and which failed + +mode: exploratory +route: /trade +depends_on: + - wallet-health.yaml + +domain: + routes: + - /trade + actions: + - switch-sell-asset + - switch-buy-asset + - search-assets + - filter-by-chain + - change-amounts + - preview-trade + - confirm-trade + - sign-swap + - toggle-fiat-crypto + +steps: + - name: Initialize swap session in fiat mode + instruction: > + Start on /trade and run toggle-fiat-crypto so all entered amounts are in USD. + Keep fiat mode enabled for the entire run. Initialize downside tracking at $0. + expected: Fiat mode active and cumulative downside tracker initialized + screenshot: true + - name: Select sell and buy assets across chains + instruction: > + Run switch-sell-asset and switch-buy-asset to create diverse pairs across + first-class EVM chains plus Solana, Cosmos Hub, THORChain, and Mayachain. + Use filter-by-chain and search-assets between selections to broaden coverage. + expected: Diverse same-chain and cross-chain pairs are selected successfully + screenshot: true + - name: Validate balance and per-swap budget before quote + instruction: > + Before each quote, check available sell balance and cap swap size to <= $10. + If balance is insufficient, log a finding and switch to another pair instead + of forcing the trade. + expected: No quote attempt exceeds wallet balance or $10 notional + screenshot: true + - name: Quote and preview candidate swap + instruction: > + Run change-amounts and preview-trade. Wait for quote state to settle and + capture quote failures, stuck loading, or malformed route details as findings. + If price impact is greater than 5%, log the finding and skip execution. + expected: Valid quotes can be previewed; >5% impact routes are skipped and reported + screenshot: true + - name: Confirm and sign eligible swaps only + instruction: > + For swaps that pass checks, run confirm-trade then sign-swap. If signing or + broadcast fails, record the failure and continue exploring other pairs. + Report swaps taking longer than 120s as slow swaps. + expected: Eligible swaps execute, and failures/slow swaps are recorded + screenshot: true + - name: Enforce cumulative downside guardrail + instruction: > + After each completed swap, estimate downside (fees + gas + slippage/impact) + and update cumulative downside. If cumulative downside reaches $4, reduce + notional to $1-$2. If cumulative downside reaches or exceeds $5, stop all + further swap executions and continue UI-only exploration. + expected: Swap execution halts once cumulative downside is >= $5 + screenshot: true + - name: Continuous findings capture + instruction: > + Throughout execution, document findings for broken quotes, failed broadcasts, + infinite loading, route errors, and UI rendering glitches. Include pair, + chain context, and reproduction notes for each finding. + expected: Findings list includes actionable details for each discovered issue + screenshot: true + +constraints: + - Maximum $10 per individual swap + - Maximum $5 cumulative downside (fees + gas + slippage) across all swaps + - Always use fiat mode for entering amounts + - Always preview before confirming + - Skip swaps with >5% price impact (report as finding) + - Check balance before each swap + - Track cumulative downside after each completed swap + - Stop executing swaps if cumulative downside reaches $5 + +goals: + - Test diverse swap pairs across all first-class chains + - Find broken quotes (no route, infinite loading, error messages) + - Find failed broadcasts or stuck transactions + - Find UI glitches during swap flow (overlapping text, missing data) + - Test rapid asset switching (change sell/buy mid-quote) + - Test edge cases (very small amounts, dust amounts) + - Discover which cross-chain routes work and which don't + - Check approval flows for ERC20 tokens + - Report any swap taking >120s as slow + +duration: 30min +max_findings: 30 diff --git a/e2e/fixtures/trade-exploration.yaml b/e2e/fixtures/trade-exploration.yaml new file mode 100644 index 00000000000..2e57c0f3976 --- /dev/null +++ b/e2e/fixtures/trade-exploration.yaml @@ -0,0 +1,80 @@ +name: Trade Page Exploration +description: > + Autonomous exploration of the trade page. The agent navigates freely + within the trade domain, trying edge cases, unusual inputs, and rapid + actions to discover bugs that scripted tests miss. + + The agent should snapshot anything that looks broken and report findings + as results with descriptive step names like: + "Found: infinite loading when switching assets rapidly" + +mode: exploratory +route: /trade +depends_on: + - wallet-health.yaml + +domain: + routes: + - /trade + actions: + - switch-assets + - search-assets + - change-amounts + - preview-trade + - filter-by-chain + - toggle-fiat-crypto + +steps: + - name: Set fiat mode for all exploration inputs + instruction: > + Run toggle-fiat-crypto first. If the sell input is already in fiat mode + (shows a $ placeholder), keep it unchanged. Keep fiat mode enabled for the + rest of this fixture. + expected: Fiat mode is enabled before any amount entry + screenshot: true + - name: Explore sell and buy asset switching + instruction: > + Run switch-assets and rotate through multiple sell/buy combinations on /trade. + Include at least one same-asset attempt to validate error handling. + Keep notional size <= $1 and do not confirm. + expected: Asset switching works and validation appears for invalid pairings + screenshot: true + - name: Filter by chain and search obscure assets + instruction: > + Run filter-by-chain, then search-assets in each selected chain view. + Try both common tokens and low-liquidity/obscure tokens. Capture any empty, + frozen, or misrendered search/filter states as findings. + expected: Chain filter and search update results without UI breakage + screenshot: true + - name: Change amounts across edge cases + instruction: > + Run change-amounts using $0, very small values, very large values, and rapid + amount edits while quotes are loading. Never exceed $1 per action. + expected: Amount changes are handled with stable validation and no infinite loading + screenshot: true + - name: Preview trade and stop before confirmation + instruction: > + Run preview-trade for valid quotes only. Stop at preview/confirm screen and do + not execute. Record findings for failed quotes, long quote loading (>30s), + incorrect fee/rate displays, and visible UI glitches. + expected: Preview opens when quote is valid; no trade confirmation is executed + screenshot: true + +constraints: + - Never spend more than $1 per action + - Never approve token spending above $5 + - Always use fiat mode for amounts + - Never confirm a trade (stop at preview/confirm screen) + - Maximum 15 minutes exploration time + - Stay on /trade route only + +goals: + - Find UI states that break (infinite loading, error screens, blank content) + - Try edge cases (0 amount, very large amounts, same asset both sides) + - Try rapid actions (switch assets mid-quote, spam preview button) + - Test with different chain filters and obscure tokens + - Look for visual glitches (overlapping text, broken layouts, missing icons) + - Check error handling (what happens when a quote fails?) + +duration: 15min +max_findings: 20 diff --git a/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseInfo.tsx b/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseInfo.tsx index c34c0723797..5a456933e2c 100644 --- a/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseInfo.tsx +++ b/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseInfo.tsx @@ -152,7 +152,7 @@ export const BackupPassphraseInfo: React.FC = props => { - + {revealed ? words : placeholders} diff --git a/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseTest.tsx b/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseTest.tsx index ebd3073b3ac..bd707838586 100644 --- a/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseTest.tsx +++ b/src/components/Layout/Header/NavBar/Native/BackupPassphraseModal/BackupPassphraseTest.tsx @@ -151,7 +151,7 @@ export const BackupPassphraseTest: React.FC = props => { translation={'modals.shapeShift.backupPassphrase.description'} mb={12} /> - + {testState.options.map((lineWords, i) => ( { - + {translate('modals.shapeShift.backupPassphrase.title')} diff --git a/src/components/MobileWalletDialog/routes/ImportWallet/ImportSeedPhrase.tsx b/src/components/MobileWalletDialog/routes/ImportWallet/ImportSeedPhrase.tsx index ecd9e820e38..8b0e94fcc54 100644 --- a/src/components/MobileWalletDialog/routes/ImportWallet/ImportSeedPhrase.tsx +++ b/src/components/MobileWalletDialog/routes/ImportWallet/ImportSeedPhrase.tsx @@ -114,7 +114,11 @@ export const ImportSeedPhrase = () => { - +