diff --git a/.changeset/firo-constants.md b/.changeset/firo-constants.md new file mode 100644 index 000000000..fb6db3a1d --- /dev/null +++ b/.changeset/firo-constants.md @@ -0,0 +1,5 @@ +--- +'@rosen-ui/constants': minor +--- + +Register the Firo network in `NETWORKS` / `NETWORKS_KEYS`. diff --git a/.changeset/firo-icons.md b/.changeset/firo-icons.md new file mode 100644 index 000000000..0d7b222d0 --- /dev/null +++ b/.changeset/firo-icons.md @@ -0,0 +1,5 @@ +--- +'@rosen-bridge/icons': minor +--- + +Add the Firo network icon. diff --git a/.changeset/firo-network-package.md b/.changeset/firo-network-package.md new file mode 100644 index 000000000..9ae0dda9d --- /dev/null +++ b/.changeset/firo-network-package.md @@ -0,0 +1,5 @@ +--- +'@rosen-network/firo': minor +--- + +Introduce the `@rosen-network/firo` package with a Firo client, unsigned-transaction generation, max-transfer calculation, and shared UTXO utilities — following the established Doge/Bitcoin UTXO pattern. diff --git a/.changeset/firo-rosen-app.md b/.changeset/firo-rosen-app.md new file mode 100644 index 000000000..aae0aa854 --- /dev/null +++ b/.changeset/firo-rosen-app.md @@ -0,0 +1,5 @@ +--- +'@rosen-bridge/rosen-app': minor +--- + +Wire Firo into the rosen app: register the Firo network module and wallet, and add the corresponding env variable entry. diff --git a/.changeset/firo-rosen-service.md b/.changeset/firo-rosen-service.md new file mode 100644 index 000000000..4f9e68e5c --- /dev/null +++ b/.changeset/firo-rosen-service.md @@ -0,0 +1,5 @@ +--- +'@rosen-bridge/rosen-service': patch +--- + +Gracefully skip networks that are present in shared constants (e.g. Firo) but not configured in this service when registering commitment extractors, instead of throwing. diff --git a/.changeset/firo-utils.md b/.changeset/firo-utils.md new file mode 100644 index 000000000..d55305fe4 --- /dev/null +++ b/.changeset/firo-utils.md @@ -0,0 +1,5 @@ +--- +'@rosen-ui/utils': patch +--- + +Add Firo entries to the `getAddressUrl`, `getTokenUrl`, and `getTxUrl` explorer helpers. diff --git a/.changeset/firo-wallet-package.md b/.changeset/firo-wallet-package.md new file mode 100644 index 000000000..260a0098c --- /dev/null +++ b/.changeset/firo-wallet-package.md @@ -0,0 +1,5 @@ +--- +'@rosen-ui/firo-wallet': minor +--- + +Introduce the `@rosen-ui/firo-wallet` package, providing browser-extension wallet support for Firo. diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 046e4cb37..c46fa658e 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -21,8 +21,8 @@ const perPackage = (resolver) => (files) => { }; const getKnipCommand = (dir) => { - const posixRelative = path.posix.relative(process.cwd(), dir); - return `knip --dependencies --workspace ${posixRelative}`; + const relative = path.relative(process.cwd(), dir).split(path.sep).join('/'); + return `knip --dependencies --workspace ${relative}`; }; const runKnipConditional = (files) => { diff --git a/apps/rosen-service/src/commitment/commitment-service.ts b/apps/rosen-service/src/commitment/commitment-service.ts index 62604bc67..270ea074c 100644 --- a/apps/rosen-service/src/commitment/commitment-service.ts +++ b/apps/rosen-service/src/commitment/commitment-service.ts @@ -19,10 +19,26 @@ export const registerExtractors = async (scanner: ErgoScanner) => { for (let key of NETWORKS_KEYS) { const chain = key == NETWORKS['bitcoin-runes'].key ? BITCOIN_RUNES_CONFIG_KEY : key; + const chainConfig = (configs as Record)[chain] as + | { + addresses?: { commitment?: string }; + tokens?: { rwt?: string }; + } + | undefined; + + // Skip networks that are present in shared constants but not configured + // in this service. + if (!chainConfig?.addresses?.commitment || !chainConfig?.tokens?.rwt) { + logger.debug( + `Skipping [${chain}] commitment extractor due to missing config`, + ); + continue; + } + const commitmentExtractor = new CommitmentExtractor( `${chain}-commitment-extractor`, - [configs[chain].addresses.commitment], - configs[chain].tokens.rwt, + [chainConfig.addresses.commitment], + chainConfig.tokens.rwt, dataSource, await getTokenMap(), logger.child(`${chain}CommitmentExtractor`), diff --git a/apps/rosen-service2/src/services/assetAggregator.ts b/apps/rosen-service2/src/services/assetAggregator.ts index 40600fa79..e334b525c 100644 --- a/apps/rosen-service2/src/services/assetAggregator.ts +++ b/apps/rosen-service2/src/services/assetAggregator.ts @@ -95,7 +95,8 @@ export class AssetAggregatorService extends PeriodicTaskService { const assetBalances: Partial> = {}; await Promise.all( Object.keys(configs.chains).map(async (chain) => { - const chainConfig = configs.chains[chain as ChainChoices]; + const chainConfig = + configs.chains[chain as keyof typeof configs.chains]; if ( chain == NETWORKS.ergo.key || ('active' in chainConfig && chainConfig.active) diff --git a/apps/rosen-service2/src/services/assetDataAdapters.ts b/apps/rosen-service2/src/services/assetDataAdapters.ts index 23a0c488d..4fb03564b 100644 --- a/apps/rosen-service2/src/services/assetDataAdapters.ts +++ b/apps/rosen-service2/src/services/assetDataAdapters.ts @@ -22,7 +22,7 @@ import { createClient } from '@vercel/kv'; import { configs } from '../configs'; import { TOTAL_SUPPLY_REDIS_KEY } from '../constants'; import { TokensConfig } from '../tokensConfig'; -import { ChainChoices, TotalSupply } from '../types'; +import { TotalSupply } from '../types'; import { stringSerializer } from '../utils'; import { DBService } from './db'; @@ -82,7 +82,9 @@ export class AssetDataAdapterService extends PeriodicTaskService { * @example * const adapter = createDataAdapter(NETWORKS.bitcoin.key, { url: "https://blockstream.info" }); */ - protected createChainSpecificDataAdapter = (chain: ChainChoices) => { + protected createChainSpecificDataAdapter = ( + chain: keyof typeof configs.chains, + ) => { const tokenMap = TokensConfig.getInstance().getTokenMap(); const addresses: string[] = [ diff --git a/apps/rosen/.env.example b/apps/rosen/.env.example index bd7003aa9..bd392f158 100644 --- a/apps/rosen/.env.example +++ b/apps/rosen/.env.example @@ -7,6 +7,7 @@ CARDANO_KOIOS_API='' ERGO_EXPLORER_API='' BITCOIN_ESPLORA_API='' DOGE_BLOCKCYPHER_API='' +FIRO_BLOCKCYPHER_API='' ETHEREUM_RPC_API='' BINANCE_RPC_API='' BITCOIN_RUNES_API='' diff --git a/apps/rosen/package.json b/apps/rosen/package.json index dc8ad1dd2..a391d12a7 100644 --- a/apps/rosen/package.json +++ b/apps/rosen/package.json @@ -40,10 +40,12 @@ "@rosen-network/ergo": "^2.5.5", "@rosen-network/ethereum": "^0.4.5", "@rosen-network/evm": "^0.3.6", + "@rosen-network/firo": "^0.0.0", "@rosen-ui/asset-calculator": "^2.3.1", "@rosen-ui/constants": "^1.0.0", "@rosen-ui/data-source": "^0.2.3", "@rosen-ui/eternl-wallet": "^3.1.6", + "@rosen-ui/firo-wallet": "^0.0.0", "@rosen-ui/lace-wallet": "^3.1.6", "@rosen-ui/metamask-wallet": "^2.1.8", "@rosen-ui/my-doge-wallet": "^1.1.8", diff --git a/apps/rosen/src/networks/firo/client.ts b/apps/rosen/src/networks/firo/client.ts new file mode 100644 index 000000000..a3eb28d8b --- /dev/null +++ b/apps/rosen/src/networks/firo/client.ts @@ -0,0 +1,12 @@ +import { FiroNetwork } from '@rosen-network/firo/dist/client'; + +import { unwrapFromObject } from '@/safeServerAction'; + +import { LOCK_ADDRESSES } from '../../../configs'; +import * as actions from './server'; + +export const firo = new FiroNetwork({ + lockAddress: LOCK_ADDRESSES.firo, + nextHeightInterval: 10, + ...unwrapFromObject(actions), +}); diff --git a/apps/rosen/src/networks/firo/index.ts b/apps/rosen/src/networks/firo/index.ts new file mode 100644 index 000000000..4f1cce44f --- /dev/null +++ b/apps/rosen/src/networks/firo/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/apps/rosen/src/networks/firo/server.ts b/apps/rosen/src/networks/firo/server.ts new file mode 100644 index 000000000..d74046941 --- /dev/null +++ b/apps/rosen/src/networks/firo/server.ts @@ -0,0 +1,50 @@ +'use server'; + +import { validateAddress as validateAddressCore } from '@rosen-network/base'; +import { + calculateFee as calculateFeeCore, + generateOpReturnData as generateOpReturnDataCore, + generateUnsignedTx as generateUnsignedTxCore, + getAddressBalance as getAddressBalanceCore, + getMaxTransferCreator as getMaxTransferCore, + getMinTransferCreator, + submitTransaction as submitTransactionCore, +} from '@rosen-network/firo'; + +import { wrap } from '@/safeServerAction'; +import { getTokenMap } from '@/tokenMap/getServerTokenMap'; + +export const calculateFee = wrap(calculateFeeCore, { + cache: 10 * 60 * 1000, + traceKey: 'firo:calculateFee', +}); + +export const generateOpReturnData = wrap(generateOpReturnDataCore, { + traceKey: 'firo:generateOpReturnData', +}); + +export const generateUnsignedTx = wrap(generateUnsignedTxCore(getTokenMap), { + traceKey: 'firo:generateUnsignedTx', +}); + +export const getAddressBalance = wrap(getAddressBalanceCore, { + cache: 3000, + traceKey: 'firo:getAddressBalance', +}); + +export const getMaxTransfer = wrap(getMaxTransferCore(getTokenMap), { + traceKey: 'firo:getMaxTransfer', +}); + +export const getMinTransfer = wrap(getMinTransferCreator(getTokenMap), { + traceKey: 'firo:getMinTransfer', +}); + +export const submitTransaction = wrap(submitTransactionCore, { + traceKey: 'firo:submitTransaction', +}); + +export const validateAddress = wrap(validateAddressCore, { + cache: Infinity, + traceKey: 'firo:validateAddress', +}); diff --git a/apps/rosen/src/networks/index.ts b/apps/rosen/src/networks/index.ts index c56c522a0..7dcf17295 100644 --- a/apps/rosen/src/networks/index.ts +++ b/apps/rosen/src/networks/index.ts @@ -5,3 +5,4 @@ export * from './cardano'; export * from './doge'; export * from './ergo'; export * from './ethereum'; +export * from './firo'; diff --git a/apps/rosen/src/wallets/firo.ts b/apps/rosen/src/wallets/firo.ts new file mode 100644 index 000000000..4d1c2bc4f --- /dev/null +++ b/apps/rosen/src/wallets/firo.ts @@ -0,0 +1,9 @@ +import { FiroWallet } from '@rosen-ui/firo-wallet'; + +import { firo } from '@/networks'; +import { getTokenMap } from '@/tokenMap/getClientTokenMap'; + +export const firoWallet = new FiroWallet({ + networks: [firo], + getTokenMap, +}); diff --git a/apps/rosen/src/wallets/index.ts b/apps/rosen/src/wallets/index.ts index 24b0e7a16..eb6935db0 100644 --- a/apps/rosen/src/wallets/index.ts +++ b/apps/rosen/src/wallets/index.ts @@ -1,6 +1,7 @@ import './base'; export * from './eternl'; +export * from './firo'; export * from './lace'; export * from './metaMask'; export * from './myDoge'; diff --git a/build.sh b/build.sh index 849fc1ed3..c3f209313 100755 --- a/build.sh +++ b/build.sh @@ -36,6 +36,7 @@ if [ "$APP" == "rosen" ] || [ "$APP" == "default" ]; then npm run build --workspace networks/bitcoin npm run build --workspace networks/bitcoin-runes npm run build --workspace networks/doge + npm run build --workspace networks/firo npm run build --workspace networks/cardano npm run build --workspace networks/ergo npm run build --workspace networks/ethereum @@ -46,6 +47,7 @@ if [ "$APP" == "rosen" ] || [ "$APP" == "default" ]; then npm run build --workspace wallets/nautilus npm run build --workspace wallets/okx npm run build --workspace wallets/my-doge + npm run build --workspace wallets/firo-wallet npm run build --workspace wallets/xverse npm run build --workspace wallets/wallet-connect fi diff --git a/networks/firo/package.json b/networks/firo/package.json new file mode 100644 index 000000000..96ff94543 --- /dev/null +++ b/networks/firo/package.json @@ -0,0 +1,37 @@ +{ + "name": "@rosen-network/firo", + "version": "0.0.0", + "private": true, + "description": "This is a private package utilized within Rosen Bridge UI app", + "repository": { + "type": "git", + "url": "git+https://github.com/rosen-bridge/ui.git" + }, + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build", + "lint": "eslint --fix . && npm run prettify", + "lint:check": "eslint . && npm run prettify:check", + "prettify": "prettier --write . --ignore-path ../../.gitignore", + "prettify:check": "prettier --check . --ignore-path ../../.gitignore", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rosen-bridge/address-codec": "^1.2.0", + "@rosen-bridge/bitcoin-utxo-selection": "^2.0.3", + "@rosen-bridge/icons": "^3.5.0", + "@rosen-bridge/tokens": "^6.0.0", + "@rosen-network/base": "^0.5.2", + "@rosen-ui/constants": "^1.0.0", + "@rosen-ui/types": "^0.4.1", + "axios": "^1.7.2", + "bitcoinjs-lib": "^6.1.6" + }, + "engines": { + "node": ">=22.18.0", + "npm": "11.6.2" + } +} diff --git a/networks/firo/src/client.ts b/networks/firo/src/client.ts new file mode 100644 index 000000000..88f50421d --- /dev/null +++ b/networks/firo/src/client.ts @@ -0,0 +1,78 @@ +import { Firo as FiroIcon } from '@rosen-bridge/icons'; +import { Network, NetworkConfig } from '@rosen-network/base'; +import { NETWORKS } from '@rosen-ui/constants'; + +import type { generateUnsignedTx } from './generateUnsignedTx'; +import type { + generateOpReturnData, + getAddressBalance, + submitTransaction, +} from './utils'; + +type FiroNetworkConfig = NetworkConfig & { + generateOpReturnData: typeof generateOpReturnData; + generateUnsignedTx: ReturnType; + getAddressBalance: typeof getAddressBalance; + submitTransaction: typeof submitTransaction; +}; + +export class FiroNetwork implements Network { + public label = NETWORKS.firo.label; + + public lockAddress: string; + + public logo = FiroIcon; + + public name = NETWORKS.firo.key; + + public nextHeightInterval: number; + + constructor(protected config: FiroNetworkConfig) { + this.nextHeightInterval = config.nextHeightInterval; + this.lockAddress = config.lockAddress; + } + + public calculateFee: FiroNetworkConfig['calculateFee'] = (...args) => { + return this.config.calculateFee(...args); + }; + + public generateOpReturnData: FiroNetworkConfig['generateOpReturnData'] = ( + ...args + ) => { + return this.config.generateOpReturnData(...args); + }; + + public generateUnsignedTx: FiroNetworkConfig['generateUnsignedTx'] = ( + ...args + ) => { + return this.config.generateUnsignedTx(...args); + }; + + public getAddressBalance: FiroNetworkConfig['getAddressBalance'] = ( + ...args + ) => { + return this.config.getAddressBalance(...args); + }; + + public getMaxTransfer: FiroNetworkConfig['getMaxTransfer'] = (...args) => { + return this.config.getMaxTransfer(...args); + }; + + public getMinTransfer: FiroNetworkConfig['getMinTransfer'] = (...args) => { + return this.config.getMinTransfer(...args); + }; + + public submitTransaction: FiroNetworkConfig['submitTransaction'] = ( + ...args + ) => { + return this.config.submitTransaction(...args); + }; + + public toSafeAddress = (address: string): string => { + return address; + }; + + public validateAddress = (walletAddress: string): Promise => { + return this.config.validateAddress(this.name, walletAddress); + }; +} diff --git a/networks/firo/src/constants.ts b/networks/firo/src/constants.ts new file mode 100644 index 000000000..cda44342f --- /dev/null +++ b/networks/firo/src/constants.ts @@ -0,0 +1,16 @@ +export const CONFIRMATION_TARGET = 10; +export const FIRO_TX_BASE_SIZE = 10; +export const FIRO_INPUT_SIZE = 148; +export const FIRO_OUTPUT_SIZE = 34; +export const MINIMUM_UTXO_VALUE = 500000; // 0.005 FIRO in sats +export const FIRO_NETWORK = { + messagePrefix: '\x18Firo Signed Message:\n', + bech32: 'firo', + bip32: { + public: 0x0488b21e, + private: 0x0488ade4, + }, + pubKeyHash: 0x52, + scriptHash: 0x07, + wif: 0xd2, +}; diff --git a/networks/firo/src/generateUnsignedTx.ts b/networks/firo/src/generateUnsignedTx.ts new file mode 100644 index 000000000..f79c4d21b --- /dev/null +++ b/networks/firo/src/generateUnsignedTx.ts @@ -0,0 +1,130 @@ +import { + BitcoinBoxSelection, + generateFeeEstimator, +} from '@rosen-bridge/bitcoin-utxo-selection'; +import { TokenMap, RosenChainToken } from '@rosen-bridge/tokens'; +import { handleUncoveredAssets } from '@rosen-network/base'; +import { NETWORKS } from '@rosen-ui/constants'; +import { RosenAmountValue } from '@rosen-ui/types'; +import { Psbt, address, payments } from 'bitcoinjs-lib'; + +import { + FIRO_NETWORK, + FIRO_INPUT_SIZE, + FIRO_TX_BASE_SIZE, + FIRO_OUTPUT_SIZE, +} from './constants'; +import { FiroUtxo, UnsignedPsbtData } from './types'; +import { + getAddressUtxos, + getFeeRatio, + getMinimumMeaningfulFiro, + getTxHex, +} from './utils'; + +const selector = new BitcoinBoxSelection(); + +/** + * generates firo lock tx + * @param getTokenMap + * @returns + */ +export const generateUnsignedTx = + (getTokenMap: () => Promise) => + async ( + lockAddress: string, + fromAddress: string, + wrappedAmount: RosenAmountValue, + opReturnData: string, + token: RosenChainToken, + ): Promise => { + const tokenMap = await getTokenMap(); + const unwrappedAmount = tokenMap.unwrapAmount( + token.tokenId, + wrappedAmount, + NETWORKS.firo.key, + ).amount; + + // generate txBuilder + const psbt = new Psbt({ network: FIRO_NETWORK }); + + // generate OP_RETURN box + const opReturnPayment = payments.embed({ + data: [Buffer.from(opReturnData, 'hex')], + }); + psbt.addOutput({ + script: opReturnPayment.output!, + value: 0, + }); + + // generate lock box + const lockScript = address.toOutputScript(lockAddress, FIRO_NETWORK); + psbt.addOutput({ + script: lockScript, + value: Number(unwrappedAmount), + }); + + // fetch inputs + const utxos = await getAddressUtxos(fromAddress); + const feeRatio = await getFeeRatio(); + const minFiro = getMinimumMeaningfulFiro(feeRatio); + + // generate fee estimator + const estimateFee = generateFeeEstimator( + 1, + FIRO_TX_BASE_SIZE, + FIRO_INPUT_SIZE, + FIRO_OUTPUT_SIZE, + feeRatio, + 1, // Firo does not use segwit + ); + + const coveredBoxes = await selector.getCoveringBoxes( + { + nativeToken: unwrappedAmount, + tokens: [], + }, + [], + new Map(), + utxos.values(), + minFiro, + undefined, + estimateFee, + ); + if (!coveredBoxes.covered) { + handleUncoveredAssets( + tokenMap, + NETWORKS.firo.key, + coveredBoxes.uncoveredAssets, + ); + } + + // add inputs + const fromAddressScript = address.toOutputScript(fromAddress, FIRO_NETWORK); + const txToHex: Record = {}; + for (const box of coveredBoxes.boxes) { + if (!txToHex[box.txId]) { + const boxTxHex = await getTxHex(box.txId); + txToHex[box.txId] = boxTxHex; + } + psbt.addInput({ + hash: box.txId, + index: box.index, + nonWitnessUtxo: Buffer.from(txToHex[box.txId], 'hex'), + }); + } + + // add change + psbt.addOutput({ + script: fromAddressScript, + value: Number(coveredBoxes.additionalAssets.aggregated.nativeToken), + }); + + return { + psbt: { + base64: psbt.toBase64(), + hex: psbt.toHex(), + }, + inputSize: psbt.inputCount, + }; + }; diff --git a/networks/firo/src/getMaxTransfer.ts b/networks/firo/src/getMaxTransfer.ts new file mode 100644 index 000000000..fe4fb5b76 --- /dev/null +++ b/networks/firo/src/getMaxTransfer.ts @@ -0,0 +1,69 @@ +import { TokenMap } from '@rosen-bridge/tokens'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network, RosenAmountValue } from '@rosen-ui/types'; + +import { + estimateTxSize, + generateOpReturnData, + getFeeRatio, + getAddressUtxos, + getMinimumMeaningfulFiro, +} from './utils'; + +export const getMaxTransferCreator = + (getTokenMap: () => Promise) => + async ({ + balance, + isNative, + eventData, + }: { + balance: RosenAmountValue; + isNative: boolean; + eventData: { + toChain: Network; + fromAddress: string; + toAddress: string; + }; + }) => { + const tokenMap = await getTokenMap(); + if (!eventData.toAddress) return 0n; + + const feeRatio = await getFeeRatio(); + const opReturnDataLength = ( + await generateOpReturnData( + eventData.toChain, + eventData.toAddress, + // We don't care about the actual op return data and only need the length + '0', + '0', + ) + ).length; + const utxos = await getAddressUtxos(eventData.fromAddress); + const estimatedTxSize = await estimateTxSize( + /** + * When getting max transfer, probably all of the utxos are going to be + * spent + */ + utxos.length, + 2, + opReturnDataLength, + ); + const estimatedFee = Math.ceil(estimatedTxSize * feeRatio); + const minFiro = getMinimumMeaningfulFiro(feeRatio); + + const offset = tokenMap.wrapAmount( + NETWORKS.firo.nativeToken, + BigInt(estimatedFee) + minFiro, + NETWORKS.firo.key, + ).amount; + + return balance < 0n || !isNative + ? 0n + : /** + * We need to subtract (utxos.length + 1) from the calculated value because + * of a bug in firo box selection + * + * local:ergo/rosen-bridge/utils#204 + */ + balance - offset - BigInt(utxos.length + 1); + }; diff --git a/networks/firo/src/index.ts b/networks/firo/src/index.ts new file mode 100644 index 000000000..f3c03ed3c --- /dev/null +++ b/networks/firo/src/index.ts @@ -0,0 +1,5 @@ +export * from './constants'; +export * from './generateUnsignedTx'; +export * from './getMaxTransfer'; +export * from './types'; +export * from './utils'; diff --git a/networks/firo/src/types.ts b/networks/firo/src/types.ts new file mode 100644 index 000000000..a3598fe3e --- /dev/null +++ b/networks/firo/src/types.ts @@ -0,0 +1,47 @@ +export interface Status { + confirmed: boolean; + block_height?: number; + block_hash?: string; + block_time?: number; +} + +export interface FiroUtxo { + txId: string; + index: number; + value: bigint; +} + +// BlockCypher types that match our needs +export interface BlockCypherAddress { + address: string; + total_received: number; + total_sent: number; + balance: number; + unconfirmed_balance: number; + final_balance: number; + n_tx: number; + unconfirmed_n_tx: number; + final_n_tx: number; + txrefs: BlockCypherTxRef[]; +} + +export interface BlockCypherTxRef { + tx_hash: string; + block_height: number; + tx_input_n: number; + tx_output_n: number; + value: number; + ref_balance: number; + spent: boolean; + confirmations: number; + confirmed: string; + double_spend: boolean; +} + +export interface UnsignedPsbtData { + psbt: { + base64: string; + hex: string; + }; + inputSize: number; +} diff --git a/networks/firo/src/utils.ts b/networks/firo/src/utils.ts new file mode 100644 index 000000000..366f75463 --- /dev/null +++ b/networks/firo/src/utils.ts @@ -0,0 +1,197 @@ +import { encodeAddress } from '@rosen-bridge/address-codec'; +import { + CalculateFee, + calculateFeeCreator, + getMinTransferCreator as getMinTransferCreatorBase, +} from '@rosen-network/base'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network } from '@rosen-ui/types'; +import Axios from 'axios'; +import { Psbt, address } from 'bitcoinjs-lib'; + +import { + FIRO_TX_BASE_SIZE, + FIRO_INPUT_SIZE, + MINIMUM_UTXO_VALUE, + FIRO_OUTPUT_SIZE, + FIRO_NETWORK, +} from './constants'; +import type { FiroUtxo, BlockCypherAddress } from './types'; + +/** + * generates metadata for lock transaction + * @param toChain + * @param toAddress + * @param networkFee + * @param bridgeFee + * @returns + */ +export const generateOpReturnData = async ( + toChain: Network, + toAddress: string, + networkFee: string, + bridgeFee: string, +): Promise => { + // parse toChain + const toChainCode = (NETWORKS[toChain]?.index ?? -1) as number; + if (toChainCode === -1) throw Error(`invalid toChain [${toChain}]`); + const toChainHex = toChainCode.toString(16).padStart(2, '0'); + + // parse bridgeFee + const bridgeFeeHex = BigInt(bridgeFee).toString(16).padStart(16, '0'); + + // parse networkFee + const networkFeeHex = BigInt(networkFee).toString(16).padStart(16, '0'); + + // parse toAddress + const addressHex = encodeAddress(toChain, toAddress); + const addressLengthCode = (addressHex.length / 2) + .toString(16) + .padStart(2, '0'); + + return Promise.resolve( + toChainHex + bridgeFeeHex + networkFeeHex + addressLengthCode + addressHex, + ); +}; + +/** + * gets utxos by address from BlockCypher + * @param address + * @returns + */ +export const getAddressUtxos = async ( + address: string, +): Promise> => { + const blockcypherUrl = `${process.env.FIRO_BLOCKCYPHER_API}`; + const GET_ADDRESS = `${blockcypherUrl}/v1/firo/main/addrs/${address}?unspentOnly=true&limit=500`; + const res = await Axios.get(GET_ADDRESS); + + return res.data.txrefs.map((txref) => ({ + txId: txref.tx_hash, + index: txref.tx_output_n, + value: BigInt(txref.value), + })); +}; + +/** + * gets tx hex by txId from BlockCypher + * @param txId + * @returns + */ +export const getTxHex = async (txId: string): Promise => { + const blockcypherUrl = `${process.env.FIRO_BLOCKCYPHER_API}`; + const GET_TX = `${blockcypherUrl}/v1/firo/main/txs/${txId}?includeHex=true`; + const res = await Axios.get<{ hex: string }>(GET_TX); + return res.data.hex; +}; + +/** + * gets address Firo balance from BlockCypher + * @param address + * @returns this is a UNWRAPPED-VALUE amount + */ +export const getAddressBalance = async (address: string): Promise => { + const blockcypherUrl = `${process.env.FIRO_BLOCKCYPHER_API}`; + const GET_ADDRESS = `${blockcypherUrl}/v1/firo/main/addrs/${address}`; + const res = await Axios.get(GET_ADDRESS); + return BigInt(res.data.final_balance); +}; + +/** + * gets current fee ratio of the network from BlockCypher + * @returns + */ +export const getFeeRatio = async (): Promise => { + const blockcypherUrl = `${process.env.FIRO_BLOCKCYPHER_API}`; + const GET_CHAIN = `${blockcypherUrl}/v1/firo/main`; + const res = await Axios.get<{ high_fee_per_kb: number }>(GET_CHAIN); + // Convert satoshis per KB to satoshis per byte + return res.data.high_fee_per_kb / 1000; +}; + +/** + * gets the minimum amount of satoshi for a utxo that can cover + * additional fee for adding it to a tx + * @returns the minimum UNWRAPPED-VALUE amount + */ +export const getMinimumMeaningfulFiro = (feeRatio: number): bigint => { + return BigInt(Math.ceil(feeRatio * FIRO_INPUT_SIZE) + MINIMUM_UTXO_VALUE); +}; + +/** + * estimates tx size based on number of inputs and outputs + * inputs and outputs required fee are estimated by FIRO_INPUT_SIZE and FIRO_OUTPUT_SIZE + * @param inputSize + * @param outputSize + * @param opReturnLength + */ +export const estimateTxSize = ( + inputSize: number, + outputSize: number, + opReturnLength: number, +): number => { + const x = + FIRO_TX_BASE_SIZE + + 11 + // OP_RETURN output base size + opReturnLength / 2 + // OP_RETURN size in bytes + inputSize * FIRO_INPUT_SIZE + // inputs size + outputSize * FIRO_OUTPUT_SIZE; // outputs size + return x; +}; + +/** + * submits a transaction to BlockCypher + * @param serializedPsbt psbt in base64 or hex format + * @param encoding psbt encoding ('base64' or 'hex') + */ +export const submitTransaction = async ( + serializedPsbt: string, + encoding: 'base64' | 'hex', +): Promise => { + const blockcypherUrl = `${process.env.FIRO_BLOCKCYPHER_API}`; + const POST_TX = `${blockcypherUrl}/v1/firo/main/txs/push`; + + const psbt = + encoding === 'base64' + ? Psbt.fromBase64(serializedPsbt) + : Psbt.fromHex(serializedPsbt); + psbt.finalizeAllInputs(); + const txHex = psbt.extractTransaction().toHex(); + + const res = await Axios.post<{ tx: { hash: string } }>(POST_TX, { + tx: txHex, + }); + return res.data.tx.hash; +}; + +export const isValidAddress = (addr: string) => { + try { + address.toOutputScript(addr, FIRO_NETWORK); + return true; + } catch { + // If an error is thrown, the address is invalid + return false; + } +}; + +export const getHeight = async (): Promise => { + const response = await fetch( + `${process.env.FIRO_BLOCKCYPHER_API}/v1/firo/main`, + ); + + const data = await response.json(); + + const height = data.height; + + return height; +}; + +export const calculateFee: CalculateFee = calculateFeeCreator( + NETWORKS.firo.key, + getHeight, +); + +export const getMinTransferCreator = getMinTransferCreatorBase( + NETWORKS.firo.key, + calculateFee, +); diff --git a/networks/firo/tsconfig.json b/networks/firo/tsconfig.json new file mode 100644 index 000000000..2f1ed6adb --- /dev/null +++ b/networks/firo/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/package-lock.json b/package-lock.json index ff302e6b2..b4d69d53b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,10 +98,12 @@ "@rosen-network/ergo": "^2.5.5", "@rosen-network/ethereum": "^0.4.5", "@rosen-network/evm": "^0.3.6", + "@rosen-network/firo": "^0.0.0", "@rosen-ui/asset-calculator": "^2.3.1", "@rosen-ui/constants": "^1.0.0", "@rosen-ui/data-source": "^0.2.3", "@rosen-ui/eternl-wallet": "^3.1.6", + "@rosen-ui/firo-wallet": "^0.0.0", "@rosen-ui/lace-wallet": "^3.1.6", "@rosen-ui/metamask-wallet": "^2.1.8", "@rosen-ui/my-doge-wallet": "^1.1.8", @@ -450,6 +452,26 @@ "npm": "11.6.2" } }, + "networks/firo": { + "name": "@rosen-network/firo", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@rosen-bridge/address-codec": "^1.2.0", + "@rosen-bridge/bitcoin-utxo-selection": "^2.0.3", + "@rosen-bridge/icons": "^3.5.0", + "@rosen-bridge/tokens": "^6.0.0", + "@rosen-network/base": "^0.5.2", + "@rosen-ui/constants": "^1.0.0", + "@rosen-ui/types": "^0.4.1", + "axios": "^1.7.2", + "bitcoinjs-lib": "^6.1.6" + }, + "engines": { + "node": ">=22.18.0", + "npm": "11.6.2" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "dev": true, @@ -6551,6 +6573,10 @@ "resolved": "networks/evm", "link": true }, + "node_modules/@rosen-network/firo": { + "resolved": "networks/firo", + "link": true + }, "node_modules/@rosen-ui/asset-aggregator": { "resolved": "packages/asset-aggregator", "link": true @@ -6575,6 +6601,10 @@ "resolved": "wallets/eternl", "link": true }, + "node_modules/@rosen-ui/firo-wallet": { + "resolved": "wallets/firo-wallet", + "link": true + }, "node_modules/@rosen-ui/lace-wallet": { "resolved": "wallets/lace", "link": true @@ -21735,6 +21765,21 @@ "npm": "11.6.2" } }, + "wallets/firo-wallet": { + "name": "@rosen-ui/firo-wallet", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@rosen-network/firo": "^0.0.0", + "@rosen-ui/constants": "^1.0.0", + "@rosen-ui/types": "^0.4.1", + "@rosen-ui/wallet-api": "^3.1.0" + }, + "engines": { + "node": ">=22.18.0", + "npm": "11.6.2" + } + }, "wallets/lace": { "name": "@rosen-ui/lace-wallet", "version": "3.1.6", diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index cfc149ac5..cff2f29b3 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -65,6 +65,14 @@ export const NETWORKS = { id: '', hasTokenSupport: false, }, + 'firo': { + index: 7, + key: 'firo', + label: 'Firo', + nativeToken: 'firo', + id: '', + hasTokenSupport: false, + }, } as const; export const NETWORKS_KEYS = Object.values(NETWORKS).map( diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 18d5961e9..9c967bb02 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -163,6 +163,7 @@ export { default as Doge } from './networks/doge.svg?react'; export { default as Ergo } from './networks/ergo.svg?react'; export { default as Ethereum } from './networks/ethereum.svg?react'; +export { default as Firo } from './networks/firo.svg?react'; export const TOKENS = { 'fcfca7654fb0da57ecf9a3f489bcbeb1d43b56dce7e73b352f7bc6f2561d2a1b': new URL( diff --git a/packages/icons/src/networks/firo.svg b/packages/icons/src/networks/firo.svg new file mode 100644 index 000000000..df15b50b5 --- /dev/null +++ b/packages/icons/src/networks/firo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/packages/utils/src/getAddressUrl.ts b/packages/utils/src/getAddressUrl.ts index 366a64e60..4fdea1190 100644 --- a/packages/utils/src/getAddressUrl.ts +++ b/packages/utils/src/getAddressUrl.ts @@ -9,6 +9,7 @@ const baseAddressURLs: { [key in Network]: string } = { [NETWORKS['bitcoin-runes'].key]: 'https://uniscan.cc/address', [NETWORKS.ethereum.key]: 'https://etherscan.io/address', [NETWORKS.doge.key]: 'https://blockexplorer.one/dogecoin/mainnet/address', + [NETWORKS.firo.key]: 'https://explorer.firo.org/address', }; export const getAddressUrl = ( diff --git a/packages/utils/src/getTokenUrl.ts b/packages/utils/src/getTokenUrl.ts index 66cb748be..c3542b808 100644 --- a/packages/utils/src/getTokenUrl.ts +++ b/packages/utils/src/getTokenUrl.ts @@ -9,6 +9,7 @@ const baseTokenURLs: { [key in Network]: string } = { [NETWORKS['bitcoin-runes'].key]: 'https://unisat.io/runes/detail', [NETWORKS.ethereum.key]: 'https://etherscan.io/token', [NETWORKS.doge.key]: '', + [NETWORKS.firo.key]: '', }; export const getTokenUrl = ( diff --git a/packages/utils/src/getTxUrl.ts b/packages/utils/src/getTxUrl.ts index 7cc3f95bb..1d6070cc5 100644 --- a/packages/utils/src/getTxUrl.ts +++ b/packages/utils/src/getTxUrl.ts @@ -18,6 +18,7 @@ const baseTxURLs: { [key in Network]: HttpsURL } = { [NETWORKS['bitcoin-runes'].key]: 'https://uniscan.cc/tx', [NETWORKS.ethereum.key]: 'https://etherscan.io/tx', [NETWORKS.doge.key]: 'https://blockexplorer.one/dogecoin/mainnet/tx', + [NETWORKS.firo.key]: 'https://explorer.firo.org/tx', }; export const getTxURL = ( diff --git a/wallets/firo-wallet/package.json b/wallets/firo-wallet/package.json new file mode 100644 index 000000000..0b60d5888 --- /dev/null +++ b/wallets/firo-wallet/package.json @@ -0,0 +1,32 @@ +{ + "name": "@rosen-ui/firo-wallet", + "version": "0.0.0", + "private": true, + "description": "This is a private package utilized within Rosen Bridge UI app", + "repository": { + "type": "git", + "url": "git+https://github.com/rosen-bridge/ui.git" + }, + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build", + "lint": "eslint --fix . && npm run prettify", + "lint:check": "eslint . && npm run prettify:check", + "prettify": "prettier --write . --ignore-path ../../.gitignore", + "prettify:check": "prettier --check . --ignore-path ../../.gitignore", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rosen-network/firo": "^0.0.0", + "@rosen-ui/constants": "^1.0.0", + "@rosen-ui/types": "^0.4.1", + "@rosen-ui/wallet-api": "^3.1.0" + }, + "engines": { + "node": ">=22.18.0", + "npm": "11.6.2" + } +} diff --git a/wallets/firo-wallet/src/icon.ts b/wallets/firo-wallet/src/icon.ts new file mode 100644 index 000000000..1a5078417 --- /dev/null +++ b/wallets/firo-wallet/src/icon.ts @@ -0,0 +1,6 @@ +export const ICON = ` + + + F + +`; diff --git a/wallets/firo-wallet/src/index.ts b/wallets/firo-wallet/src/index.ts new file mode 100644 index 000000000..3c5958cf6 --- /dev/null +++ b/wallets/firo-wallet/src/index.ts @@ -0,0 +1 @@ +export * from './wallet'; diff --git a/wallets/firo-wallet/src/types.ts b/wallets/firo-wallet/src/types.ts new file mode 100644 index 000000000..57b8da33c --- /dev/null +++ b/wallets/firo-wallet/src/types.ts @@ -0,0 +1,33 @@ +import { WalletConfig } from '@rosen-ui/wallet-api'; + +export type FiroWalletConfig = WalletConfig & {}; + +/** + * global type augmentation for the firo wallet + */ +declare global { + interface Window { + firo: { + connect: () => Promise<{ + approved: boolean; + address: string; + balance: string; + publicKey: string; + }>; + disconnect: () => Promise<{ disconnected: true }>; + signAndBroadcastTransaction: (params: { + rawTx: string; + indexes: number[]; + signOnly: boolean; + }) => Promise; + getBalance: () => Promise<{ + address: string; + balance: string; + }>; + getConnectionStatus: () => Promise<{ + isConnected: boolean; + selectedWalletAddress: string; + }>; + }; + } +} diff --git a/wallets/firo-wallet/src/wallet.ts b/wallets/firo-wallet/src/wallet.ts new file mode 100644 index 000000000..f77e2c460 --- /dev/null +++ b/wallets/firo-wallet/src/wallet.ts @@ -0,0 +1,88 @@ +import { FiroNetwork } from '@rosen-network/firo/dist/client'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network } from '@rosen-ui/types'; +import { + UnsupportedChainError, + UserDeniedTransactionSignatureError, + Wallet, + WalletTransferParams, +} from '@rosen-ui/wallet-api'; + +import { ICON } from './icon'; +import { FiroWalletConfig } from './types'; + +export class FiroWallet extends Wallet { + icon = ICON; + + name = 'Firo'; + + label = 'Firo'; + + link = 'https://firo.org/'; + + currentChain: Network = NETWORKS.firo.key; + + supportedChains: Network[] = [NETWORKS.firo.key]; + + private get api() { + return window.firo; + } + + performConnect = async (): Promise => { + if (await this.isConnected()) return; + await this.api.connect(); + }; + + performDisconnect = async (): Promise => { + await this.api.disconnect(); + }; + + fetchAddress = async (): Promise => { + return (await this.api.getConnectionStatus()).selectedWalletAddress; + }; + + fetchBalance = async (): Promise => { + return (await this.api.getBalance()).balance; + }; + + isAvailable = (): boolean => { + return typeof window.firo !== 'undefined' && !!window.firo; + }; + + hasConnection = async (): Promise => { + return (await this.api.getConnectionStatus()).isConnected; + }; + + performTransfer = async (params: WalletTransferParams): Promise => { + if (!(this.currentNetwork instanceof FiroNetwork)) { + throw new UnsupportedChainError(this.name, this.currentChain); + } + + const userAddress = await this.getAddress(); + + const opReturnData = await this.currentNetwork.generateOpReturnData( + params.toChain, + params.address, + params.networkFee.toString(), + params.bridgeFee.toString(), + ); + + const psbtData = await this.currentNetwork.generateUnsignedTx( + params.lockAddress, + userAddress, + params.amount, + opReturnData, + params.token, + ); + + try { + return await this.api.signAndBroadcastTransaction({ + rawTx: psbtData.psbt.hex, + indexes: Array.from(Array(psbtData.inputSize).keys()), + signOnly: false, + }); + } catch (error) { + throw new UserDeniedTransactionSignatureError(this.name, error); + } + }; +} diff --git a/wallets/firo-wallet/tsconfig.json b/wallets/firo-wallet/tsconfig.json new file mode 100644 index 000000000..2f1ed6adb --- /dev/null +++ b/wallets/firo-wallet/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src"] +}