diff --git a/.changeset/blue-gorillas-rush.md b/.changeset/blue-gorillas-rush.md new file mode 100644 index 00000000..4775f36d --- /dev/null +++ b/.changeset/blue-gorillas-rush.md @@ -0,0 +1,5 @@ +--- +'@fireblocks/recovery-shared': patch +--- + +updated x-key types for import and vault shared pages diff --git a/.changeset/dry-paws-thank.md b/.changeset/dry-paws-thank.md new file mode 100644 index 00000000..ffedbbb9 --- /dev/null +++ b/.changeset/dry-paws-thank.md @@ -0,0 +1,5 @@ +--- +'@fireblocks/recovery-utility': minor +--- + +Display each keyset with their enabled keys diff --git a/.changeset/eleven-cheetahs-rest.md b/.changeset/eleven-cheetahs-rest.md new file mode 100644 index 00000000..bdaf5db1 --- /dev/null +++ b/.changeset/eleven-cheetahs-rest.md @@ -0,0 +1,5 @@ +--- +'@fireblocks/recovery-utility': minor +--- + +get specific keyset when x-keys are required diff --git a/.changeset/giant-cups-explode.md b/.changeset/giant-cups-explode.md new file mode 100644 index 00000000..02a7c082 --- /dev/null +++ b/.changeset/giant-cups-explode.md @@ -0,0 +1,6 @@ +--- +'@fireblocks/recovery-utility': minor +'@fireblocks/recovery-shared': minor +--- + +allow to map keyset when no keyset threshold mapping is defined in drs kit diff --git a/.changeset/old-drinks-camp.md b/.changeset/old-drinks-camp.md new file mode 100644 index 00000000..277003dd --- /dev/null +++ b/.changeset/old-drinks-camp.md @@ -0,0 +1,5 @@ +--- +'@fireblocks/recovery-shared': minor +--- + +Added type distinction for relay and utility in workspace definition and support for keyset account mapping diff --git a/.changeset/orange-bobcats-taste.md b/.changeset/orange-bobcats-taste.md new file mode 100644 index 00000000..562c69ea --- /dev/null +++ b/.changeset/orange-bobcats-taste.md @@ -0,0 +1,5 @@ +--- +'@fireblocks/extended-key-recovery': patch +--- + +Updated tests for multiple keysets diff --git a/.changeset/tender-turtles-destroy.md b/.changeset/tender-turtles-destroy.md new file mode 100644 index 00000000..c3814ca7 --- /dev/null +++ b/.changeset/tender-turtles-destroy.md @@ -0,0 +1,6 @@ +--- +'@fireblocks/recovery-utility': minor +'@fireblocks/recovery-shared': minor +--- + +Moved x-key page to utility only diff --git a/.changeset/violet-ducks-joke.md b/.changeset/violet-ducks-joke.md new file mode 100644 index 00000000..f758d303 --- /dev/null +++ b/.changeset/violet-ducks-joke.md @@ -0,0 +1,5 @@ +--- +'@fireblocks/extended-key-recovery': minor +--- + +Added recovery for multiple keysets diff --git a/.changeset/wild-timers-think.md b/.changeset/wild-timers-think.md new file mode 100644 index 00000000..e7637147 --- /dev/null +++ b/.changeset/wild-timers-think.md @@ -0,0 +1,6 @@ +--- +'@fireblocks/wallet-derivation': minor +'@fireblocks/recovery-shared': minor +--- + +Make derivation and base wallet to use correct keyset for account created or used diff --git a/.gitignore b/.gitignore index 5efce77a..6aca789c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,10 @@ dependencies node_modules .pnp .pnp.js - +.claude/ # Generated dependency attributions ATTRIBUTION.md +.gitlab-ci.yml # Testing coverage diff --git a/README.md b/README.md index 28ef04aa..35cee7bc 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,6 @@ Recover Fireblocks assets and keys in a disaster, verify a Recovery Kit, or generate keys to set up a new Recovery Kit.

-

- ⬇️ Latest version 1.8.1 - Download for macOS / Linux via the Fireblocks Console -

-

Screenshot diff --git a/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx b/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx index 3aa15302..628addfe 100644 --- a/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx +++ b/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx @@ -60,7 +60,7 @@ const getRPCKey = ( return baseAsset; }; -export const getAssetURL = ( +export const getAssetURLAndApiKey = ( assetId: string, RPCs: Record< string, @@ -69,16 +69,18 @@ export const getAssetURL = ( allowedEmptyValue: boolean; name: string; url?: string | null | undefined; + requiresApiKey?: boolean; + apiKey?: string | null; } >, -): string | null | undefined => { +): { url: string; requiresApiKey?: boolean; apiKey?: string | null } | null | undefined => { const rpcKey = getRPCKey(assetId, RPCs); if (rpcKey === undefined) { return undefined; } const assetRPCData = RPCs[rpcKey]; - const { url } = assetRPCData; + const { url, requiresApiKey, apiKey } = assetRPCData; logger.info(`RPC URL for ${rpcKey} is ${assetRPCData.enabled ? 'enabled' : 'disabled'} and is ${url}`); if (assetRPCData.allowedEmptyValue && (url === null || url === undefined)) { @@ -88,7 +90,7 @@ export const getAssetURL = ( // If not enabled we shouldn't be getting a request to the relay for it regardless. return undefined; } - return url; + return { url: url as string, requiresApiKey, apiKey }; }; const getWallet = (accounts: Map>, accountId?: number, assetId?: string) => { @@ -152,8 +154,9 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse const values = watch(); const fromAddress = values.fromAddress ?? defaultValues.fromAddress; - - const derivation = wallet?.derivations?.get(getDerivationMapKey(asset?.id, fromAddress)); + const derivation = fromAddress + ? wallet?.derivations?.get(getDerivationMapKey(asset?.id, fromAddress)) ?? wallet?.derivations?.get(fromAddress) + : undefined; // TODO: Show both original balance and adjusted balance in create tx UI @@ -164,7 +167,12 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse enabled: !!derivation, queryFn: async () => { logger.debug(`Querying prepare transaction ${toAddress}`); - const rpcUrl = getAssetURL(derivation?.assetId ?? '', RPCs); + const data = getAssetURLAndApiKey(derivation?.assetId ?? '', RPCs); + if (!data) { + throw new Error(`No RPC data for: ${derivation?.assetId}`); + } + const { url: rpcUrl, requiresApiKey, apiKey } = data; + if (rpcUrl === undefined) { logger.error(`Unknown URL for ${derivation?.assetId ?? ''}`); throw new Error(`No RPC Url for: ${derivation?.assetId}`); @@ -185,6 +193,13 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse (derivation as ERC20).setToAddress(toAddress); } if (rpcUrl !== null) derivation!.setRPCUrl(rpcUrl); // this must remain the last method called on derivation for ERC20 support + if (requiresApiKey) { + if (!apiKey || apiKey === '') { + throw new Error(`RPC for ${derivation?.assetId} requires an API key. Please set one in the Settings page.`); + } else { + derivation!.setAPIKey(apiKey); + } + } return await derivation!.prepare?.(toAddress, values.memo); }, @@ -328,6 +343,12 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse {prepareQuery.error || typeof prepareQuery.data?.balance === 'undefined' ? 'Could not get balance' : `${prepareQuery.data.balance} ${asset.id}`} + {prepareQuery.error && ( + <> +
+ {prepareQuery.error.message} + + )} )} @@ -337,7 +358,11 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse id={addressExplorerId} variant='outlined' component={NextLinkComposed} - to={asset.getExplorerUrl?.('address')(fromAddress ?? '')} + to={ + asset.nativeAsset === 'XRP' + ? asset.getExplorerUrl?.('accounts')(fromAddress ?? '') + : asset.getExplorerUrl?.('address')(fromAddress ?? '') + } target='_blank' rel='noopener noreferrer' > diff --git a/apps/recovery-relay/components/WithdrawModal/index.tsx b/apps/recovery-relay/components/WithdrawModal/index.tsx index c7f80332..bbe38d55 100644 --- a/apps/recovery-relay/components/WithdrawModal/index.tsx +++ b/apps/recovery-relay/components/WithdrawModal/index.tsx @@ -17,7 +17,7 @@ import { sanatize } from '@fireblocks/recovery-shared/lib/sanatize'; import { getAssetConfig, isTransferableToken } from '@fireblocks/asset-config/util'; import { SignOrBroadcastTransaction } from '@fireblocks/recovery-shared/components'; import { useWorkspace } from '../../context/Workspace'; -import { CreateTransaction, getAssetURL } from './CreateTransaction'; +import { CreateTransaction, getAssetURLAndApiKey } from './CreateTransaction'; import { LateInitConnectedWallet } from '../../lib/wallets/LateInitConnectedWallet'; import { useSettings } from '../../context/Settings'; import { ERC20 } from '../../lib/wallets/ERC20'; @@ -83,11 +83,18 @@ export const WithdrawModal = () => { const { assetId } = params.signedTx; const wallet = accounts.get(params.accountId)?.wallets.get(assetId); - const derivation = wallet?.derivations?.get(getDerivationMapKey(assetId, params.signedTx.from)); + const fromAddress = params.signedTx.from; + const derivation = fromAddress + ? wallet?.derivations?.get(getDerivationMapKey(assetId, fromAddress)) ?? wallet?.derivations?.get(fromAddress) + : undefined; if (isTransferableToken(assetId) && derivation instanceof ERC20) { (derivation as ERC20).setNativeAsset(getAssetConfig(assetId)!.nativeAsset); } - const rpcUrl = getAssetURL(derivation?.assetId ?? '', RPCs); + const data = getAssetURLAndApiKey(derivation?.assetId ?? '', RPCs); + if (!data) { + throw new Error(`No RPC data for: ${derivation?.assetId}`); + } + const { url: rpcUrl, requiresApiKey, apiKey } = data; if (rpcUrl === undefined) { throw new Error(`No RPC URL for asset ${derivation?.assetId}`); } else if (rpcUrl === null) { @@ -98,12 +105,20 @@ export const WithdrawModal = () => { derivation?.setRPCUrl(rpcUrl); } + if (requiresApiKey) { + if (!apiKey || apiKey === '') { + throw new Error(`RPC for ${derivation?.assetId} requires an API key`); + } else { + derivation!.setAPIKey(apiKey); + } + } + const signedTxHex = params.signedTx.hex; const cleanDerivation = derivation ? sanatize(derivation) : undefined; logger.info('Derivation and signed transaction hash:', { cleanDerivation, signedTxHex }); try { - const newTxHash = await derivation?.broadcastTx(signedTxHex); + const newTxHash = await derivation?.broadcastTx(signedTxHex, logger, derivation?.assetId); setTxHash(newTxHash); logger.info({ newTxHash }); @@ -118,6 +133,13 @@ export const WithdrawModal = () => { logger.info('Outbound URL', { outboundRelayUrl }); + if (outboundRelayUrl) { + console.log('='.repeat(80)); + console.log('COPY THIS RELAY URL:'); + console.log(outboundRelayUrl); + console.log('='.repeat(80)); + } + return ( { }, []); const saveSettings = async (data: RelaySettingsInput) => { - logger.info('Storing new settings', data); - window.localStorage.setItem('settings', JSON.stringify(data)); + try { + logger.info('Storing new settings', data); + window.localStorage.setItem('settings', JSON.stringify(data)); + setSettings(data); + } catch (error) { + logger.error('Failed to save settings', error); + throw error; + } }; // eslint-disable-next-line react/jsx-no-constructed-context-values diff --git a/apps/recovery-relay/lib/defaultRPCs.ts b/apps/recovery-relay/lib/defaultRPCs.ts index 180d9492..cbe539de 100644 --- a/apps/recovery-relay/lib/defaultRPCs.ts +++ b/apps/recovery-relay/lib/defaultRPCs.ts @@ -2,27 +2,40 @@ export const defaultRPCs: Record< string, { url: string | undefined | null; + broadcastUrl?: string; name: string; enabled: boolean; allowedEmptyValue: boolean; + requiresApiKey?: boolean; + apiKey?: string | null; } > = { ALGO: { url: null, allowedEmptyValue: true, enabled: true, name: 'Algorand' }, ALGO_TEST: { url: null, allowedEmptyValue: true, enabled: true, name: 'Algorand Testnet' }, - AVAX: { url: 'https://api.avax-test.network/ext/bc/C/rpc', name: 'Avalance C-Chain', enabled: true, allowedEmptyValue: false }, + AVAX: { url: 'https://api.avax.network/ext/bc/C/rpc', name: 'Avalance C-Chain', enabled: true, allowedEmptyValue: false }, AVAXTEST: { - url: 'https://api.avax.network/ext/bc/C/rpc', + url: 'https://api.avax-test.network/ext/bc/C/rpc', name: 'Avalance C-Chain Testnet', enabled: true, allowedEmptyValue: false, }, FLR: { url: 'https://flare-api.flare.network/ext/C/rpc', name: 'Flare', enabled: true, allowedEmptyValue: false }, - BTC: { url: 'https://api.blockchair.com/bitcoin', name: 'Bitcoin', enabled: true, allowedEmptyValue: false }, + BTC: { + url: 'https://api.blockchair.com/bitcoin', + name: 'Bitcoin', + enabled: true, + allowedEmptyValue: false, + requiresApiKey: true, + apiKey: null, + }, BTC_TEST: { url: 'https://api.blockchair.com/bitcoin/testnet', + broadcastUrl: 'https://blockstream.info/testnet/api/tx', name: 'Bitcoin Testnet', enabled: true, allowedEmptyValue: false, + requiresApiKey: true, + apiKey: null, }, BCH: { url: 'https://rest.bch.actorforth.org/v2', name: 'Bitcoin Cash', enabled: true, allowedEmptyValue: false }, BCH_TEST: { url: undefined, name: 'Bitcoin Cash Testnet', enabled: false, allowedEmptyValue: false }, @@ -33,16 +46,44 @@ export const defaultRPCs: Record< enabled: true, allowedEmptyValue: false, }, - DOGE: { url: 'https://api.blockchair.com/dogecoin', name: 'Dogecoin', enabled: true, allowedEmptyValue: false }, + DOGE: { + url: 'https://api.blockchair.com/dogecoin', + name: 'Dogecoin', + enabled: true, + allowedEmptyValue: false, + requiresApiKey: true, + apiKey: null, + }, DOGE_TEST: { url: undefined, name: 'Dogecoin Testnet', enabled: false, allowedEmptyValue: false }, DOT: { url: 'wss://rpc.polkadot.io', name: 'Polkadot', enabled: true, allowedEmptyValue: false }, WND: { url: 'wss://westend-rpc.polkadot.io', name: 'Westend (Polkadot Testnet)', enabled: true, allowedEmptyValue: false }, KSM: { url: 'wss://kusama-rpc.polkadot.io', name: 'Kusama', enabled: true, allowedEmptyValue: false }, - LTC: { url: 'https://api.blockchair.com/litecoin', name: 'Litecoin', enabled: true, allowedEmptyValue: false }, + LTC: { + url: 'https://api.blockchair.com/litecoin', + name: 'Litecoin', + enabled: true, + allowedEmptyValue: false, + requiresApiKey: true, + apiKey: null, + }, LTC_TEST: { url: undefined, name: 'Litecoin Testnet', enabled: false, allowedEmptyValue: false }, - ZEC: { url: 'https://api.blockchair.com/zcash', name: 'Litecoin', enabled: true, allowedEmptyValue: false }, + ZEC: { + url: 'https://api.blockchair.com/zcash', + name: 'Litecoin', + enabled: true, + allowedEmptyValue: false, + requiresApiKey: true, + apiKey: null, + }, ZEC_TEST: { url: undefined, name: 'ZCash Testnet', enabled: false, allowedEmptyValue: false }, - DASH: { url: 'https://api.blockchair.com/Dash', name: 'Dash', enabled: true, allowedEmptyValue: false }, + DASH: { + url: 'https://api.blockchair.com/Dash', + name: 'Dash', + enabled: true, + allowedEmptyValue: false, + requiresApiKey: true, + apiKey: null, + }, FTM_FANTOM: { url: 'https://rpcapi.fantom.network', name: 'Fantom Testnet', enabled: true, allowedEmptyValue: false }, ETC: { url: 'https://geth-de.etc-network.info', name: 'Ethereum Classic', enabled: true, allowedEmptyValue: false }, ETC_TEST: { @@ -64,7 +105,7 @@ export const defaultRPCs: Record< allowedEmptyValue: false, }, ETH_TEST5: { - url: 'https://ethereum-sepolia-rpc.publicnode.com', + url: 'https://sepolia.drpc.org', name: 'Ethereum Sepolia Testnet', enabled: true, allowedEmptyValue: false, diff --git a/apps/recovery-relay/lib/wallets/BTCBased/BTC.ts b/apps/recovery-relay/lib/wallets/BTCBased/BTC.ts index 48ca7fc9..fed3238a 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/BTC.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/BTC.ts @@ -1,110 +1,31 @@ /* eslint-disable max-classes-per-file */ import { Bitcoin as BaseBTC, Input } from '@fireblocks/wallet-derivation'; import { CustomElectronLogger } from '@fireblocks/recovery-shared/lib/getLogger'; -import { AccountData, BTCLegacyUTXO, BTCSegwitUTXO } from '../types'; +import { AccountData } from '../types'; import { ConnectedWallet } from '../ConnectedWallet'; import { BTCRelayWallet } from './BTCRelayWallet'; import { BTCRelayWalletUtils, StandardBTCRelayWalletUtils } from './BTCRelayWalletUtils'; -import { AddressSummary, FullUTXO, StandardUTXO, UTXOSummary } from './types'; export class Bitcoin extends BaseBTC implements ConnectedWallet { private static readonly satsPerBtc = 100000000; public rpcURL: string | undefined; + public apiKey: string | null = null; private utils: BTCRelayWalletUtils | undefined; constructor(input: Input) { super(input); - // Legacy requires a custom site - if (this.isLegacy) { - // When calling any custom function provided as part of the relay wallet utils - // we bind it to `this` from the BTCRelayWallet class, thus every internal reference to this - // within the custom functions must be considered as a call to the BTCRelayWalletUtils and not - // overarching wallet type (BSV in this case) - - this.utils = new (class { - btcWalletUtils; - - constructor(baseUrl: string) { - this.btcWalletUtils = new StandardBTCRelayWalletUtils(baseUrl); - } - - async getAddressUTXOs(address: string): Promise { - const utxoSummary = await this.btcWalletUtils.requestJson.bind(this)(`/address/${address}/utxo`); - return utxoSummary.map((utxo) => ({ - transaction_hash: utxo.txid, - value: utxo.value, - index: utxo.vout, - block_id: utxo.status.block_height ?? -1, - })); - } - - async getAddressBalance(address: string): Promise { - const { chain_stats: chainStats } = await this.btcWalletUtils.requestJson.bind(this)( - `/address/${address}`, - ); - return chainStats.funded_txo_sum - chainStats.spent_txo_sum; - } - - async getFeeRate(): Promise { - const feeEstimate = await this.btcWalletUtils.requestJson.bind(this)<{ [key: string]: number }>('/fee-estimates'); - const feeRate = feeEstimate['1']; - return feeRate; - } - - async getLegacyFullUTXO(utxo: StandardUTXO): Promise { - const { transaction_hash: hash, index } = utxo; - const rawTxRes = await this.btcWalletUtils.request(`/tx/${hash}/raw`); - const rawTx = await rawTxRes.arrayBuffer(); - const nonWitnessUtxo = Buffer.from(rawTx); - - return { - hash, - index, - nonWitnessUtxo, - confirmed: true, - value: BTCRelayWallet._satsToBtc(utxo.value), - }; - } - - async getSegwitUTXO(utxo: StandardUTXO): Promise { - const { transaction_hash: hash, index } = utxo; - const fullUtxo = await this.btcWalletUtils.requestJson.bind(this)(`/tx/${hash}`); - const { scriptpubkey, value } = fullUtxo.vout[index]; - - return { - hash, - index, - witnessUtxo: { script: scriptpubkey, value }, - confirmed: true, - value: BTCRelayWallet._satsToBtc(value), - }; - } - - async broadcastTx(txHex: string, logger: CustomElectronLogger): Promise { - try { - const txBroadcastRes = await this.btcWalletUtils.request('/tx', { - method: 'POST', - body: txHex, - }); - - const txHash = await txBroadcastRes.text(); - if (txHash.length !== 64) { - throw new Error(txHash); - } - return txHash; - } catch (e) { - logger.error(`BTC: Error broadcasting tx: ${JSON.stringify(e, null, 2)}`); - throw e; - } - } - })(this.isTestnet ? 'https://blockstream.info/testnet/api' : 'https://blockstream.info/api') as BTCRelayWalletUtils; - } } public setRPCUrl(url: string): void { this.rpcURL = url; + this.utils = new StandardBTCRelayWalletUtils(this.rpcURL, undefined, false, this.apiKey); + } + + public setAPIKey(apiKey: string | null): void { + this.apiKey = apiKey; + this.utils = new StandardBTCRelayWalletUtils(this.rpcURL!, undefined, false, this.apiKey); } public async getBalance(): Promise { @@ -115,7 +36,7 @@ export class Bitcoin extends BaseBTC implements ConnectedWallet { return BTCRelayWallet.prototype.prepare.bind(this)(); } - public async broadcastTx(txHex: string): Promise { - return BTCRelayWallet.prototype.broadcastTx.bind(this)(txHex); + public async broadcastTx(txHex: string, logger: CustomElectronLogger, assetId?: string | undefined): Promise { + return BTCRelayWallet.prototype.broadcastTx.bind(this)(txHex, logger, assetId); } } diff --git a/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWallet.ts b/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWallet.ts index 03034d96..f7d87a37 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWallet.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWallet.ts @@ -1,3 +1,4 @@ +import { CustomElectronLogger } from '@fireblocks/recovery-shared/lib/getLogger'; import { AccountData, LegacyUTXOType, SegwitUTXOType } from '../types'; import { BTCRelayWalletUtils, StandardBTCRelayWalletUtils } from './BTCRelayWalletUtils'; @@ -9,9 +10,9 @@ export class BTCRelayWallet { } public async getBalance(): Promise { - // @ts-ignore - const utils = (this.utils as BTCRelayWalletUtils) || new StandardBTCRelayWalletUtils(this.rpcURL); - // @ts-ignore + const utils = + // @ts-ignore + (this.utils as BTCRelayWalletUtils) || new StandardBTCRelayWalletUtils(this.rpcURL, undefined, false, this.apiKey); // @ts-ignore const balance = await utils.getAddressBalance(this.address); const btcBalance = BTCRelayWallet._satsToBtc(balance); return btcBalance; @@ -19,10 +20,10 @@ export class BTCRelayWallet { public async prepare(): Promise { // @ts-ignore - const { isLegacy, relayLogger: logger, rpcURL, address } = this; + const { isLegacy, relayLogger: logger, rpcURL, address, apiKey } = this; // @ts-ignore - const utils = (this.utils as BTCRelayWalletUtils) || new StandardBTCRelayWalletUtils(rpcURL); + const utils = (this.utils as BTCRelayWalletUtils) || new StandardBTCRelayWalletUtils(rpcURL, undefined, false, apiKey); const balance = await BTCRelayWallet.prototype.getBalance.bind(this)(); if (balance === 0) { @@ -52,17 +53,17 @@ export class BTCRelayWallet { return preparedData as AccountData; } - public async broadcastTx(txHex: string): Promise { + public async broadcastTx(txHex: string, logger?: CustomElectronLogger, assetId?: string | undefined): Promise { // BTC Tx are automatically signed and resulting hex is signed, so no need to do anything special. // const tx = Psbt.fromHex(txHex, { network: this.network }); // @ts-ignore - const { relayLogger: logger, rpcURL } = this; + const { relayLogger: relayLogger, rpcURL, apiKey } = this; // @ts-ignore - const utils = (this.utils as BTCRelayWalletUtils) || new StandardBTCRelayWalletUtils(rpcURL); + const utils = (this.utils as BTCRelayWalletUtils) || new StandardBTCRelayWalletUtils(rpcURL, undefined, false, apiKey); // @ts-ignore - return utils.broadcastTx(txHex, logger); + return utils.broadcastTx(txHex, relayLogger, assetId); } } diff --git a/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWalletUtils.ts b/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWalletUtils.ts index 44a5216a..65e9977b 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWalletUtils.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/BTCRelayWalletUtils.ts @@ -3,6 +3,7 @@ import { ipcRenderer } from 'electron'; import { BTCLegacyUTXO, BTCSegwitUTXO } from '../types'; import { BTCRelayWallet } from './BTCRelayWallet'; import { StandardAddressSummary, StandardBlockchainStats, StandardFullUTXO, StandardUTXO } from './types'; +import { defaultRPCs } from '../../defaultRPCs'; export interface BTCRelayWalletUtils { getAddressUTXOs: (address: string) => Promise; @@ -10,11 +11,24 @@ export interface BTCRelayWalletUtils { getFeeRate: () => Promise; getLegacyFullUTXO?: (utxo: StandardUTXO) => Promise; getSegwitUTXO: (utxo: StandardUTXO) => Promise; - broadcastTx?: (txHex: string, logger: CustomElectronLogger) => Promise; + broadcastTx?: (txHex: string, logger: CustomElectronLogger, assetId?: string | undefined) => Promise; } export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { - constructor(private baseUrl: string, private overrides?: BTCRelayWalletUtils, private fetchOnMain = false) {} + constructor( + private baseUrl: string, + private overrides?: BTCRelayWalletUtils, + private fetchOnMain = false, + private apiKey: string | null = null, + ) {} + + public setAPIKey(apiKey: string | null): void { + this.apiKey = apiKey; + } + + public getApiKey(): string | null { + return this.apiKey; + } async request(path: string, init?: RequestInit) { let res: Response; @@ -37,7 +51,9 @@ export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { if (this.overrides && this.overrides.getAddressUTXOs) { return this.overrides.getAddressUTXOs(address); } - const addressSummary = await this.requestJson(`/dashboards/address/${address}?limit=0,10000`); + const addressSummary = await this.requestJson( + `/dashboards/address/${address}?limit=0,10000&key=${this.apiKey}`, + ); return addressSummary.data[address].utxo; } @@ -46,8 +62,9 @@ export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { if (this.overrides && this.overrides.getAddressBalance) { return this.overrides.getAddressBalance(address); } - const { balance } = (await this.requestJson(`/dashboards/address/${address}?limit=0,0`)).data[address] - .address; + const { balance } = ( + await this.requestJson(`/dashboards/address/${address}?limit=0,0&key=${this.apiKey}`) + ).data[address].address; return balance; } @@ -56,7 +73,7 @@ export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { if (this.overrides && this.overrides.getFeeRate) { return this.overrides.getFeeRate(); } - const bcStats = await this.requestJson('/stats'); + const bcStats = await this.requestJson(`/stats?key=${this.apiKey}`); const feeRate = bcStats.data.suggested_transaction_fee_per_byte_sat; return feeRate; } @@ -68,9 +85,15 @@ export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { // } const { transaction_hash: hash, index } = utxo; - const rawTxRes = await this.request(`/tx/${hash}/raw`); - const rawTx = await rawTxRes.arrayBuffer(); - const nonWitnessUtxo = Buffer.from(rawTx); + const txData = await this.requestJson<{ data: { [key: string]: { raw_transaction: string } } }>( + `/raw/transaction/${hash}?key=${this.apiKey}`, + ); + console.log('Blockchair raw tx response:', { hash, txData }); + const rawTxHex = txData.data[hash].raw_transaction; + console.log('Raw tx hex:', { hash, rawTxHex, length: rawTxHex?.length }); + + const hexBytes = rawTxHex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []; + const nonWitnessUtxo = new Uint8Array(hexBytes); return { hash, @@ -87,7 +110,7 @@ export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { } const { transaction_hash: hash, index } = utxo; - const fullUtxo = await this.requestJson(`/dashboards/transaction/${hash}`); + const fullUtxo = await this.requestJson(`/dashboards/transaction/${hash}?key=${this.apiKey}`); if (fullUtxo.data[hash].transaction.block_id === -1) { return undefined; } @@ -102,17 +125,41 @@ export class StandardBTCRelayWalletUtils implements BTCRelayWalletUtils { }; } - async broadcastTx(txHex: string, logger: CustomElectronLogger): Promise { + async broadcastTx(txHex: string, logger: CustomElectronLogger, assetId?: string | undefined): Promise { if (this.overrides && this.overrides.broadcastTx) { return this.overrides.broadcastTx(txHex, logger); } + // Use Blockstream for Bitcoin Testnet as Blockhiar returns 500 + if (assetId === 'BTC_TEST') { + const broadcastUrl = defaultRPCs.BTC_TEST.broadcastUrl; + + if (!broadcastUrl) { + throw new Error('No broadcast URL for BTC Testnet'); + } + + logger.info('Broadcasting via Blockstream testnet...'); + const res = await fetch(broadcastUrl, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: txHex, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Blockstream broadcast failed: ${res.status} ${text}`); + } + + logger.info(`Broadcast successful via Blockstream: ${text}`); + return text.trim(); + } + try { const txBroadcastRes: { data?: { transaction_hash: string; [key: string]: any }; context: { code: number; error: string; [key: string]: any }; } = await ( - await this.request('/push/transaction', { + await this.request(`/push/transaction?key=${this.apiKey}`, { method: 'POST', body: `data=${txHex}`, headers: [['Content-Type', 'application/x-www-form-urlencoded']], diff --git a/apps/recovery-relay/lib/wallets/BTCBased/DASH.ts b/apps/recovery-relay/lib/wallets/BTCBased/DASH.ts index 041e8bf9..4ec57326 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/DASH.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/DASH.ts @@ -5,6 +5,7 @@ import { BTCRelayWallet } from './BTCRelayWallet'; export class DASH extends BaseDASH implements ConnectedWallet { public rpcURL: string | undefined; + public apiKey: string | null = null; constructor(input: Input) { super(input); @@ -18,6 +19,10 @@ export class DASH extends BaseDASH implements ConnectedWallet { this.rpcURL = url; } + public setAPIKey(apiKey: string | null): void { + this.apiKey = apiKey; + } + public async getBalance(): Promise { return BTCRelayWallet.prototype.getBalance.bind(this)(); } diff --git a/apps/recovery-relay/lib/wallets/BTCBased/DOGE.ts b/apps/recovery-relay/lib/wallets/BTCBased/DOGE.ts index 27203eb1..2bfc996c 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/DOGE.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/DOGE.ts @@ -7,11 +7,16 @@ export class DOGE extends BaseDOGE implements ConnectedWallet { private static readonly satsPerBtc = 100000000; public rpcURL: string | undefined; + public apiKey: string | null = null; public setRPCUrl(url: string): void { this.rpcURL = url; } + public setAPIKey(apiKey: string | null): void { + this.apiKey = apiKey; + } + public async prepare(): Promise { return BTCRelayWallet.prototype.prepare.bind(this)(); } diff --git a/apps/recovery-relay/lib/wallets/BTCBased/LTC.ts b/apps/recovery-relay/lib/wallets/BTCBased/LTC.ts index e4fad6e2..cc658bb7 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/LTC.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/LTC.ts @@ -7,11 +7,16 @@ export class LTC extends BaseLTC implements ConnectedWallet { private static readonly satsPerBtc = 100000000; public rpcURL: string | undefined; + public apiKey: string | null = null; public setRPCUrl(url: string): void { this.rpcURL = url; } + public setAPIKey(apiKey: string | null): void { + this.apiKey = apiKey; + } + public async getBalance(): Promise { return BTCRelayWallet.prototype.getBalance.bind(this)(); } diff --git a/apps/recovery-relay/lib/wallets/BTCBased/ZEC.ts b/apps/recovery-relay/lib/wallets/BTCBased/ZEC.ts index 35a6706a..bad1869e 100644 --- a/apps/recovery-relay/lib/wallets/BTCBased/ZEC.ts +++ b/apps/recovery-relay/lib/wallets/BTCBased/ZEC.ts @@ -8,11 +8,16 @@ export class ZEC extends BaseZEC implements ConnectedWallet { private static readonly satsPerBtc = 100000000; public rpcURL: string | undefined; + public apiKey: string | null = null; public setRPCUrl(url: string): void { this.rpcURL = url; } + public setAPIKey(apiKey: string | null): void { + this.apiKey = apiKey; + } + public async prepare(): Promise { const currentBlock = await this._getCurrentBlockHeight(); const extraParams = new Map(); @@ -30,7 +35,7 @@ export class ZEC extends BaseZEC implements ConnectedWallet { } private async _getCurrentBlockHeight() { - const utils = new StandardBTCRelayWalletUtils(this.rpcURL!); + const utils = new StandardBTCRelayWalletUtils(this.rpcURL!, undefined, false, this.apiKey); const stats = await utils.requestJson<{ data: { blocks: number; diff --git a/apps/recovery-relay/lib/wallets/ConnectedWallet.ts b/apps/recovery-relay/lib/wallets/ConnectedWallet.ts index a895425c..194f2702 100644 --- a/apps/recovery-relay/lib/wallets/ConnectedWallet.ts +++ b/apps/recovery-relay/lib/wallets/ConnectedWallet.ts @@ -1,5 +1,6 @@ import { BaseWallet } from '@fireblocks/wallet-derivation'; import { AccountData } from './types'; +import { CustomElectronLogger } from '@fireblocks/recovery-shared/lib/getLogger'; export abstract class ConnectedWallet extends BaseWallet { public rpcURL: string | undefined; @@ -8,7 +9,7 @@ export abstract class ConnectedWallet extends BaseWallet { public abstract prepare(to?: string, memo?: string): Promise; - public abstract broadcastTx(txHex: string): Promise; + public abstract broadcastTx(txHex: string, logger?: CustomElectronLogger, assetId?: string | undefined): Promise; public abstract setRPCUrl(url: string): void; } diff --git a/apps/recovery-relay/lib/wallets/XLM/index.ts b/apps/recovery-relay/lib/wallets/XLM/index.ts index ccc6d165..7b9e0da2 100644 --- a/apps/recovery-relay/lib/wallets/XLM/index.ts +++ b/apps/recovery-relay/lib/wallets/XLM/index.ts @@ -1,5 +1,5 @@ import { Stellar as BaseXLM } from '@fireblocks/wallet-derivation'; -import { AccountResponse, Networks, Server, Transaction, xdr } from 'stellar-sdk'; +import { AccountResponse, Networks, NotFoundError, Server, Transaction, xdr } from 'stellar-sdk'; import { ConnectedWallet } from '../ConnectedWallet'; import { AccountData } from '../types'; @@ -25,13 +25,27 @@ export class Stellar extends BaseXLM implements ConnectedWallet { return parseFloat(nativeBalances[0].balance); } - public async prepare(): Promise { + public async prepare(toAddress: string): Promise { const balance = await this.getBalance(); const sequence = this.account!.sequenceNumber(); const extraParams = new Map(); extraParams.set(this.KEY_SEQUENCE, sequence); extraParams.set(this.KEY_ACCOUNT_ID, this.account!.accountId()); + extraParams.set('ACCOUNT_BALANCE', balance); + + let destinationExists = true; + try { + await this.xlmServer!.loadAccount(toAddress); + } catch (err) { + if (err instanceof NotFoundError) { + destinationExists = false; + } else { + throw err; + } + } + + extraParams.set('DESTINATION_EXISTS', destinationExists); const preparedData = { balance, diff --git a/apps/recovery-relay/lib/wallets/XRP/index.ts b/apps/recovery-relay/lib/wallets/XRP/index.ts index 86bf0fed..3e1e2289 100644 --- a/apps/recovery-relay/lib/wallets/XRP/index.ts +++ b/apps/recovery-relay/lib/wallets/XRP/index.ts @@ -15,6 +15,56 @@ export class Ripple extends BaseRipple implements ConnectedWallet { this.xrpClient = new Client(url); } + /** + * Calculate the minimum XRP balance required (base reserve + owner reserve) + */ + private async getMinimumReserve(): Promise { + if (!this.xrpClient!.isConnected()) { + await this.xrpClient!.connect(); + } + + const accountInfo = await this.xrpClient!.request({ + command: 'account_info', + account: this.address, + ledger_index: 'validated', + }); + + const ownerCount = accountInfo.result.account_data.OwnerCount || 0; + + const serverInfo = await this.xrpClient!.request({ + command: 'server_info', + }); + + const validatedLedger = serverInfo.result.info.validated_ledger; + + if (!validatedLedger) { + // Fallback to current known mainnet values if server hasn't validated yet + const defaultReserveBase = 1; + const defaultReserveInc = 0.2; + + this.relayLogger.warn( + `XRP server hasn't validated a ledger yet. Using default reserve values: base=${defaultReserveBase}, increment=${defaultReserveInc}`, + ); + + return defaultReserveBase + ownerCount * defaultReserveInc; + } + + const reserveBase = validatedLedger.reserve_base_xrp; + const reserveInc = validatedLedger.reserve_inc_xrp; + + if (reserveBase === undefined || reserveInc === undefined) { + throw new Error('Failed to retrieve reserve requirements from XRP Ledger server'); + } + + const totalReserve = reserveBase + ownerCount * reserveInc; + + this.relayLogger.debug( + `XRP Reserve calculation: base=${reserveBase}, increment=${reserveInc}, ownerCount=${ownerCount}, total=${totalReserve}`, + ); + + return totalReserve; + } + public async getBalance(): Promise { if (!this.xrpClient!.isConnected()) { await this.xrpClient!.connect(); @@ -27,6 +77,9 @@ export class Ripple extends BaseRipple implements ConnectedWallet { public async prepare(): Promise { const balance = await this.getBalance(); + + const minReserve = await this.getMinimumReserve(); + // Fee calculation const netFeeXRP = await getFeeXrp(this.xrpClient!); const netFeeDrops = xrpToDrops(netFeeXRP); @@ -49,11 +102,12 @@ export class Ripple extends BaseRipple implements ConnectedWallet { }) ).result.account_data.Sequence, ); + const availableBalance = balance - minReserve; const preparedData = { - balance: parseFloat((balance - this.MIN_XRP_BALANCE).toFixed(6)), + balance: parseFloat(availableBalance.toFixed(6)), extraParams, - insufficientBalance: balance - this.MIN_XRP_BALANCE < 0.0001, + insufficientBalance: availableBalance < 0.0001, }; this.relayLogger.logPreparedData('Ripple', preparedData); diff --git a/apps/recovery-relay/lib/wallets/index.ts b/apps/recovery-relay/lib/wallets/index.ts index ae471831..b8b053a8 100644 --- a/apps/recovery-relay/lib/wallets/index.ts +++ b/apps/recovery-relay/lib/wallets/index.ts @@ -42,7 +42,7 @@ import { Jetton } from './Jetton'; import { ERC20 } from './ERC20'; import { TRC20 } from './TRC20'; import { SPL } from './SPL'; -import { getAllSpls } from '@fireblocks/asset-config/assets'; +import { getAllSpls, getAllXlms, getAllXRPs } from '@fireblocks/asset-config/assets'; import { Flare } from './EVM/FLR'; export { ConnectedWallet } from './ConnectedWallet'; @@ -99,6 +99,32 @@ const fillSpls = () => { return spls; }; +const fillXlms = () => { + const xlmsList = getAllXlms(); + const xlms = xlmsList.reduce( + (prev, curr) => ({ + ...prev, + [curr]: Stellar, + }), + {}, + ) as any; + Object.keys(xlms).forEach((key) => (xlms[key] === undefined ? delete xlms[key] : {})); + return xlms; +}; + +const fillXRPs = () => { + const xrpsList = getAllXRPs(); + const xrps = xrpsList.reduce( + (prev, curr) => ({ + ...prev, + [curr]: Ripple, + }), + {}, + ) as any; + Object.keys(xrps).forEach((key) => (xrps[key] === undefined ? delete xrps[key] : {})); + return xrps; +}; + export const WalletClasses = { ALGO: Algorand, ALGO_TEST: Algorand, @@ -142,6 +168,8 @@ export const WalletClasses = { XDC: XinFin, XRP: Ripple, XRP_TEST: Ripple, + ...fillXRPs(), + RBTC: RootstockBTC, RBTC_TEST: RootstockBTC, SGB: Songbird, @@ -188,6 +216,7 @@ export const WalletClasses = { ...fillJettons(), ...fillERC20s(), ...fillTRC20s(), + ...fillXlms(), } as const; type WalletClass = (typeof WalletClasses)[keyof typeof WalletClasses]; diff --git a/apps/recovery-relay/package.json b/apps/recovery-relay/package.json index ddccf6fd..8f479021 100644 --- a/apps/recovery-relay/package.json +++ b/apps/recovery-relay/package.json @@ -4,7 +4,7 @@ "author": "Fireblocks (https://www.fireblocks.com)", "repository": "https://github.com/fireblocks/recovery/tree/main/apps/recovery-relay", "license": "GPL-3.0-or-later", - "version": "1.6.0", + "version": "1.8.4", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/recovery-relay/pages/settings.tsx b/apps/recovery-relay/pages/settings.tsx index a1970640..9c895f63 100644 --- a/apps/recovery-relay/pages/settings.tsx +++ b/apps/recovery-relay/pages/settings.tsx @@ -5,7 +5,7 @@ import { Button, settingsInput, getLogger, DataGrid, useWrappedState } from '@fi import { Alert, Box, Grid, Typography } from '@mui/material'; import { LOGGER_NAME_RELAY } from '@fireblocks/recovery-shared/constants'; import { shell, ipcRenderer } from 'electron'; -import { BaseModal, EditIcon, TextField } from '@fireblocks/recovery-shared/components'; +import { BaseModal, EditIcon, KeyIcon, TextField } from '@fireblocks/recovery-shared/components'; import { GridActionsCellItem, GridCellParams, GridColDef, GridToolbar } from '@mui/x-data-grid'; import React, { useMemo, useState } from 'react'; import { useSettings } from '../context/Settings'; @@ -20,6 +20,8 @@ type RowData = { name: string; enabled: boolean; allowedEmptyValue: boolean; + requiresApiKey?: boolean; + apiKey?: string | null; }; const logger = getLogger(LOGGER_NAME_RELAY); @@ -27,6 +29,10 @@ const logger = getLogger(LOGGER_NAME_RELAY); const Settings = () => { const { saveSettings, RPCs: currentRPCs } = useSettings(); const [editModalData, setEditModalData] = useWrappedState('settingsRpc-editModalData', undefined); + const [addAPIKeyModalData, setAddAPIKeyModalData] = useWrappedState( + 'settingsRpc-addAPIKeyModalData', + undefined, + ); const [rpcs, setRPCs] = useState< Record< string, @@ -35,6 +41,8 @@ const Settings = () => { enabled: boolean; allowedEmptyValue: boolean; url?: string | null | undefined; + requiresApiKey?: boolean; + apiKey?: string | null; } > >(currentRPCs); @@ -42,14 +50,27 @@ const Settings = () => { shell.openPath(await ipcRenderer.invoke('logs/get_path')); }; + // Form for URL editing const { - register, - handleSubmit, - formState: { errors }, + register: registerUrl, + handleSubmit: handleSubmitUrl, + formState: { errors: urlErrors }, } = useForm({ resolver: zodResolver(settingsInput.RELAY), defaultValues: { - RPCs: defaultRPCs, + RPCs: currentRPCs, + }, + }); + + // Form for API Key editing + const { + register: registerApiKey, + handleSubmit: handleSubmitApiKey, + formState: { errors: apiKeyErrors }, + } = useForm({ + resolver: zodResolver(settingsInput.RELAY), + defaultValues: { + RPCs: currentRPCs, }, }); @@ -57,7 +78,7 @@ const Settings = () => { () => Object.keys(rpcs) .map((chain) => { - const { url, name, enabled, allowedEmptyValue } = rpcs[chain]; + const { url, name, enabled, allowedEmptyValue, requiresApiKey, apiKey } = rpcs[chain]; if (name === undefined) { throw new Error(`Blockchain without name`); } @@ -68,6 +89,8 @@ const Settings = () => { url: enabled ? url : 'No support for this network', enabled, allowedEmptyValue, + requiresApiKey, + apiKey, }, ]; }) @@ -83,12 +106,23 @@ const Settings = () => { console.log(rowInfo); setEditModalData(rowInfo); }; + + const handleAddAPIKeyModal = (rowInfo: RowData | undefined) => { + console.log(rowInfo); + setAddAPIKeyModalData(rowInfo); + }; + const handleCloseEditModal = () => { setEditModalData(undefined); }; + const handleCloseAddAPIKeyModal = () => { + setAddAPIKeyModalData(undefined); + }; + const onSubmitModal = async (formData: FormData) => { handleCloseEditModal(); + handleCloseAddAPIKeyModal(); setRPCs(formData.RPCs); await saveSettings(formData); }; @@ -99,9 +133,22 @@ const Settings = () => { return `RPCs.${blockchain}.url`; }; + const getApiKeyReactHookDotNotationPropertyAccess = (): `RPCs` | `RPCs.${string}` => { + if (addAPIKeyModalData === undefined) return 'RPCs'; + const blockchain = addAPIKeyModalData.id; + return `RPCs.${blockchain}.apiKey`; + }; + + const getApiKeyErrorMessage = (): string | undefined => { + if (addAPIKeyModalData === undefined) return undefined; + const blockchain = addAPIKeyModalData.id; + const apiKeyError = apiKeyErrors.RPCs?.[blockchain]?.apiKey; + return apiKeyError?.message; + }; + const getErrorMessage = (): string | undefined => { const dotNotationPropertyAccess = getReactHookDotNotationPropertyAccess().replace('.url', '.root'); - let currentProperty: any = errors; + let currentProperty: any = urlErrors; // eslint-disable-next-line no-restricted-syntax, guard-for-in for (const property of dotNotationPropertyAccess.split('.')) { currentProperty = currentProperty[property]; @@ -190,6 +237,23 @@ const Settings = () => { />, ], }, + { + field: 'api-key', + headerName: '', + type: 'actions', + editable: false, + sortable: false, + getActions: (params) => [ + } + disabled={!params.row.enabled || !params.row.requiresApiKey} + label='Edit Api Key' + onClick={() => handleAddAPIKeyModal(params.row as RowData)} + />, + ], + }, ] as GridColDef[] } initialState={{ @@ -215,7 +279,7 @@ const Settings = () => { } // eslint-disable-next-line react/no-unstable-nested-components - WrapperComponent={(props) =>
} + WrapperComponent={(props) => } > @@ -230,12 +294,39 @@ const Settings = () => { label={`${editModalData?.bc} RPC URL`} defaultValue={editModalData?.url} error={getErrorMessage()} - {...register(getReactHookDotNotationPropertyAccess())} + {...registerUrl(getReactHookDotNotationPropertyAccess())} /> + + + + + + } + WrapperComponent={(props) => } + > + + + + + + ); }; diff --git a/apps/recovery-utility/package.json b/apps/recovery-utility/package.json index c000dc7a..6fbd7b3e 100644 --- a/apps/recovery-utility/package.json +++ b/apps/recovery-utility/package.json @@ -4,7 +4,7 @@ "author": "Fireblocks (https://www.fireblocks.com)", "repository": "https://github.com/fireblocks/recovery/tree/main/apps/recovery-utility", "homepage": "https://www.fireblocks.com", - "version": "1.8.1", + "version": "1.8.3", "license": "GPL-3.0-or-later", "private": true, "main": "app/background.js", diff --git a/apps/recovery-utility/renderer/lib/wallets/XLM/index.ts b/apps/recovery-utility/renderer/lib/wallets/XLM/index.ts index d3bf7fe0..91b1f240 100644 --- a/apps/recovery-utility/renderer/lib/wallets/XLM/index.ts +++ b/apps/recovery-utility/renderer/lib/wallets/XLM/index.ts @@ -1,18 +1,5 @@ import { Stellar as BaseStellar } from '@fireblocks/wallet-derivation'; -import { - Account, - AccountResponse, - Asset, - Keypair, - Memo, - Networks, - Operation, - Server, - StrKey, - Transaction, - TransactionBuilder, - xdr, -} from 'stellar-sdk'; +import { Account, Asset, Memo, Networks, Operation, TransactionBuilder } from 'stellar-sdk'; import { SigningWallet } from '../SigningWallet'; import { GenerateTxInput, TxPayload } from '../types'; @@ -20,25 +7,74 @@ export class Stellar extends BaseStellar implements SigningWallet { public async generateTx({ extraParams, feeRate, to, amount, memo }: GenerateTxInput): Promise { const accountId = extraParams?.get(this.KEY_ACCOUNT_ID); const sequence = extraParams?.get(this.KEY_SEQUENCE); - const txBuilder = new TransactionBuilder(new Account(accountId, sequence), { fee: `${feeRate}` }); - txBuilder - .addOperation( + const destinationExists = extraParams?.get('DESTINATION_EXISTS'); + const accountBalance = extraParams?.get('ACCOUNT_BALANCE'); + + const txBuilder = new TransactionBuilder(new Account(accountId, sequence), { + fee: `${feeRate}`, + networkPassphrase: this.isTestnet ? Networks.TESTNET : Networks.PUBLIC, + }); + + if (!destinationExists) { + // For new accounts, use Create Account operation + // Calculate maximum we can send while keeping minimum reserves + const feeInXLM = feeRate! / 1_000_000; + const minimumReserve = 1; // 1 XLM + const maxSendable = accountBalance - feeInXLM - minimumReserve; + + if (maxSendable < 1.0) { + throw new Error( + `Insufficient balance to create account. Need at least ${ + 1.0 + feeInXLM + minimumReserve + } XLM, but have ${accountBalance} XLM`, + ); + } + + // Use the requested amount or maximum sendable + const sendAmount = Math.min(amount, maxSendable); + const finalAmount = Math.max(sendAmount, 1.0); // Ensure minimum 1 XLM for account creation + + txBuilder.addOperation( + Operation.createAccount({ + destination: to, + startingBalance: finalAmount.toString(), + }), + ); + } else { + // For existing accounts, use Payment operation + const feeInXLM = feeRate! / 1_000_000; + const minimumReserve = this.MIN_BALANCE_SMALLEST_UNITS; + const maxSendable = accountBalance - feeInXLM - minimumReserve; + + if (maxSendable <= 0) { + throw new Error( + `Insufficient balance. Need to keep ${minimumReserve} XLM reserve plus ${feeInXLM} XLM fee, but only have ${accountBalance} XLM`, + ); + } + + const sendAmount = Math.min(amount, maxSendable); + + txBuilder.addOperation( Operation.payment({ destination: to, asset: Asset.native(), - amount: `${Math.round((amount - feeRate! / 1_000_000 - this.MIN_BALANCE_SMALLEST_UNITS) * 10_000_000) / 10_000_000}`, // Floating point shinanigans + amount: sendAmount.toString(), }), - ) - .setTimeout(600) - .setNetworkPassphrase(this.isTestnet ? Networks.TESTNET : Networks.PUBLIC); + ); + } + + txBuilder.setTimeout(600); + if (memo) { txBuilder.addMemo(new Memo('text', memo)); } const tx = txBuilder.build(); this.utilityLogger.logSigningTx('Stellar', tx.toEnvelope()); + const sig = await this.sign(tx.hash()); tx.addSignature(this.address, Buffer.from(sig).toString('base64')); + return { tx: tx.toEnvelope().toXDR('hex'), }; diff --git a/apps/recovery-utility/renderer/lib/wallets/index.ts b/apps/recovery-utility/renderer/lib/wallets/index.ts index 2c03f36c..39251b16 100644 --- a/apps/recovery-utility/renderer/lib/wallets/index.ts +++ b/apps/recovery-utility/renderer/lib/wallets/index.ts @@ -25,7 +25,7 @@ import { Ton } from './TON'; import { Jetton } from './Jetton'; import { ERC20 } from './ERC20'; import { TRC20 } from './TRC20'; -import { getAllSpls } from '@fireblocks/asset-config/assets'; +import { getAllSpls, getAllXlms, getAllXRPs } from '@fireblocks/asset-config/assets'; import { FLR } from './FLR'; const fillEVMs = () => { @@ -74,6 +74,24 @@ const fillSpls = () => { return spls; }; +const fillXlms = () => { + const xlmList = getAllXlms(); + const xlms: { [key: string]: any } = {}; + for (const xlm of xlmList) { + xlms[xlm] = Stellar; + } + return xlms; +}; + +const fillXRPs = () => { + const xrpList = getAllXRPs(); + const xrps: { [key: string]: any } = {}; + for (const xrp of xrpList) { + xrps[xrp] = Ripple; + } + return xrps; +}; + export { SigningWallet as BaseWallet } from './SigningWallet'; export const WalletClasses = { @@ -123,6 +141,9 @@ export const WalletClasses = { SOL: Solana, SOL_TEST: Solana, ...fillSpls(), + ...fillXlms(), + + ...fillXRPs(), XLM: Stellar, XLM_TEST: Stellar, diff --git a/package.json b/package.json index 7c67ca2d..e29ddd97 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dev": "concurrently \"yarn workspace @fireblocks/recovery-relay dev\" \"yarn wait-on tcp:3000 && yarn workspace @fireblocks/recovery-utility dev\"", "clean": "[ -f scripts/clean.local.sh ] && ./scripts/clean.local.sh || ./scripts/clean.sh", "build": "[ -f scripts/build.local.sh ] && ./scripts/build.local.sh || ./scripts/build.sh", + "build:all": "./scripts/build-cross-platform.sh", "forcebuild": "yarn turbo run build --force", "test": "turbo run test", "lint": "turbo run lint", @@ -79,4 +80,4 @@ "wait-on": "^7.0.1", "xvfb-maybe": "^0.2.1" } -} \ No newline at end of file +} diff --git a/packages/asset-config/assets.ts b/packages/asset-config/assets.ts index f79927a1..844b8e33 100644 --- a/packages/asset-config/assets.ts +++ b/packages/asset-config/assets.ts @@ -81,3 +81,23 @@ export function getAllTRC20s(): string[] { } return trc20s; } + +export function getAllXlms(): string[] { + const xlmTokens = []; + for (const asset of globalAssets) { + if (asset.protocol === 'XLM' && asset.address) { + xlmTokens.push(asset.id); + } + } + return xlmTokens; +} + +export function getAllXRPs(): string[] { + const xrpTokens = []; + for (const asset of globalAssets) { + if (asset.protocol === 'XRP' && asset.address) { + xrpTokens.push(asset.id); + } + } + return xrpTokens; +} diff --git a/packages/asset-config/config/patches.ts b/packages/asset-config/config/patches.ts index d95d1712..e9decaaf 100644 --- a/packages/asset-config/config/patches.ts +++ b/packages/asset-config/config/patches.ts @@ -155,8 +155,20 @@ export const nativeAssetPatches: NativeAssetPatches = { ETH_TEST6: evm('holesky.etherscan.io', 'https://ethereum-holesky-rpc.publicnode.com'), 'ETH-AETH': evm('arbiscan.io'), 'ETH-AETH_RIN': evm('testnet.arbiscan.io'), + 'ETH-AETH_SEPOLIA': evm('sepolia.arbiscan.io'), 'ETH-OPT': evm('optimistic.etherscan.io'), 'ETH-OPT_KOV': evm('kovan-optimistic.etherscan.io'), + 'ETH-OPT_SEPOLIA': evm('sepolia-optimism.etherscan.io'), + BASECHAIN_ETH: evm('basescan.org', 'https://mainnet.base.org'), + BASECHAIN_ETH_TEST5: evm('sepolia.basescan.org', 'https://sepolia.base.org'), + ETH_ZKSYNC_ERA: evm('explorer.zksync.io', 'https://mainnet.era.zksync.io'), + ETH_ZKSYNC_ERA_SEPOLIA: evm('sepolia.explorer.zksync.io', 'https://sepolia.era.zksync.dev'), + ETH_ZKSYNC_ERA_TEST: evm('goerli.explorer.zksync.io', 'https://testnet.era.zksync.dev'), + ETH_ZKEVM_TEST: evm('cardona-zkevm.polygonscan.com/', 'https://rpc.public.zkevm-test.net'), + LINEA_TEST: evm('sepolia.lineascan.build', 'https://rpc.goerli.linea.build'), + LINEA_SEPOLIA_TEST: evm('sepolia.lineascan.build', 'https://rpc.sepolia.linea.build'), + SCROLL_SEPOLIA_TEST: evm('sepolia.scrollscan.com', 'https://sepolia-rpc.scroll.io'), + WORLDCHAIN_TEST: evm('worldchain-sepolia.explorer.alchemy.com', 'https://worldchain-sepolia.g.alchemy.com/public'), ETHW: evm('www.oklink.com/ethw'), EVMOS: evm('bigdipper.live/evmos', 'https://rpc.evmos.org'), FTM_FANTOM: evm('ftmscan.com', 'https://rpcapi.fantom.network'), diff --git a/packages/asset-config/types.ts b/packages/asset-config/types.ts index fea8d78c..27efabf6 100644 --- a/packages/asset-config/types.ts +++ b/packages/asset-config/types.ts @@ -5,7 +5,7 @@ type RawAsset = RawAssets[number]; export type NativeAssetId = RawAsset['nativeAsset']; -export type GetExplorerUrl = (type: 'tx' | 'address') => (value: string) => string; +export type GetExplorerUrl = (type: 'tx' | 'address' | 'accounts') => (value: string) => string; export type NativeAssetPatch = { derive?: boolean; diff --git a/packages/asset-config/util.ts b/packages/asset-config/util.ts index 4f116367..b09650b4 100644 --- a/packages/asset-config/util.ts +++ b/packages/asset-config/util.ts @@ -34,7 +34,12 @@ export const isExplorerUrl = (url: string) => { assetId in endingsToRemove ? asset.getExplorerUrl('address')('').replace(endingsToRemove[assetId], '') : asset.getExplorerUrl('address')(''); - return url.startsWith(addressUrl) || url.startsWith(txUrl); + const accountsUrl = + assetId in endingsToRemove + ? asset.getExplorerUrl('accounts')('').replace(endingsToRemove[assetId], '') + : asset.getExplorerUrl('accounts')(''); + + return url.startsWith(txUrl) || url.startsWith(addressUrl) || (accountsUrl ? url.startsWith(accountsUrl) : false); } return false; }); diff --git a/packages/shared/components/RelayRxTx/index.tsx b/packages/shared/components/RelayRxTx/index.tsx index 17d6e9f2..11923d64 100644 --- a/packages/shared/components/RelayRxTx/index.tsx +++ b/packages/shared/components/RelayRxTx/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable turbo/no-undeclared-env-vars */ import React from 'react'; import { Box, Grid, lighten, Typography } from '@mui/material'; import { CallMade, CallReceived } from '@mui/icons-material'; @@ -68,24 +67,20 @@ export const RelayRxTx = ({ rxTitle, txTitle, txUrl, onDecodeQrCode }: Props) => {!!onDecode && ( - {process.env.CI === 'e2e' ? ( - { - try { - onDecode({ data: e.target.value } as ScanResult); - } catch (exception) { - console.error(exception); - } - }} - /> - ) : ( - '' - )} + { + try { + onDecode({ data: e.target.value } as ScanResult); + } catch (exception) { + console.error(exception); + } + }} + /> )} diff --git a/packages/shared/hooks/useBaseWorkspace/reduceDerivations.ts b/packages/shared/hooks/useBaseWorkspace/reduceDerivations.ts index 129b4f60..a9149d0b 100644 --- a/packages/shared/hooks/useBaseWorkspace/reduceDerivations.ts +++ b/packages/shared/hooks/useBaseWorkspace/reduceDerivations.ts @@ -186,7 +186,8 @@ export const reduceDerivations = diff --git a/packages/wallet-derivation/wallets/chains/XRP.ts b/packages/wallet-derivation/wallets/chains/XRP.ts index 1be10373..7ed28d50 100644 --- a/packages/wallet-derivation/wallets/chains/XRP.ts +++ b/packages/wallet-derivation/wallets/chains/XRP.ts @@ -17,5 +17,5 @@ export class Ripple extends BTCWalletBase { protected readonly KEY_FEE = 'f'; protected readonly KEY_LEDGER_SEQUENCE = 'l'; - protected readonly MIN_XRP_BALANCE = 10; + // protected readonly MIN_XRP_BALANCE = 10; } diff --git a/test.txt b/test.txt new file mode 100644 index 00000000..76110cc1 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +test sync Tue Jun 3 13:32:44 IDT 2025