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
-
-
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())}
/>
+
+
+
+ Cancel
+
+ Save
+ >
+ }
+ 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