diff --git a/modules/sdk-coin-tempo/package.json b/modules/sdk-coin-tempo/package.json index 654df65f7a..d8ff493707 100644 --- a/modules/sdk-coin-tempo/package.json +++ b/modules/sdk-coin-tempo/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@bitgo/abstract-eth": "^24.22.0", + "@bitgo/sdk-lib-mpc": "^10.10.0", "@bitgo/sdk-core": "^36.37.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.32.0", diff --git a/modules/sdk-coin-tempo/src/lib/constants.ts b/modules/sdk-coin-tempo/src/lib/constants.ts index 3ee2ca9f53..780821265b 100644 --- a/modules/sdk-coin-tempo/src/lib/constants.ts +++ b/modules/sdk-coin-tempo/src/lib/constants.ts @@ -27,3 +27,14 @@ export const TIP20_DECIMALS = 6; * Tempo uses EIP-7702 Account Abstraction with transaction type 0x76 */ export const AA_TRANSACTION_TYPE = '0x76' as const; + +/** + * Fallback JSON-RPC endpoints when `common.Environments[bitgo.getEnv()].evm.tempo|ttempo.rpcUrl` + * is missing (should not happen for normal envs). Primary RPC config lives in sdk-core + * `environments.ts` under `evm.tempo` / `evm.ttempo`; `@bitgo/statics` networks only define + * explorer URLs and chainId, not RPC. + */ +export const TEMPO_RPC_URLS = { + MAINNET: 'https://rpc.mainnet.tempo.xyz', + TESTNET: 'https://rpc.testnet.tempo.xyz', +} as const; diff --git a/modules/sdk-coin-tempo/src/lib/iface.ts b/modules/sdk-coin-tempo/src/lib/iface.ts index a3155f12da..64b283c58b 100644 --- a/modules/sdk-coin-tempo/src/lib/iface.ts +++ b/modules/sdk-coin-tempo/src/lib/iface.ts @@ -1,6 +1,18 @@ /** * Interfaces for Tempo */ +import type { RecoverOptions } from '@bitgo/abstract-eth'; + +/** + * Optional Tempo-specific recovery fields (passed on {@link RecoverOptions}). + * Fees on Tempo are paid in a TIP-20 token; defaults to the recovered token. + */ +export type TempoRecoveryOptions = RecoverOptions & { + /** TIP-20 contract used to pay AA gas (defaults to token being swept) */ + feeTokenAddress?: string; + /** Override default public RPC URL */ + rpcUrl?: string; +}; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface TransactionData { diff --git a/modules/sdk-coin-tempo/src/lib/utils.ts b/modules/sdk-coin-tempo/src/lib/utils.ts index be05fb3f86..e8ca0a1c80 100644 --- a/modules/sdk-coin-tempo/src/lib/utils.ts +++ b/modules/sdk-coin-tempo/src/lib/utils.ts @@ -6,8 +6,8 @@ import { bip32 } from '@bitgo/secp256k1'; import { ethers } from 'ethers'; -import { AA_TRANSACTION_TYPE, TIP20_DECIMALS } from './constants'; -import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi'; +import { AA_TRANSACTION_TYPE, TEMPO_RPC_URLS, TIP20_DECIMALS } from './constants'; +import { TIP20_ABI, TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi'; const AA_TX_HEX_REGEX = new RegExp(`^${AA_TRANSACTION_TYPE}[0-9a-f]*$`, 'i'); @@ -149,6 +149,37 @@ export function isValidMemoId(memoId: string): boolean { return typeof memoId === 'string' && /^(0|[1-9]\d*)$/.test(memoId); } +/** + * Resolve default Tempo JSON-RPC URL from base chain name. + */ +export function getTempoRpcUrlForBaseChain(baseChain: string): string { + return baseChain === 'ttempo' ? TEMPO_RPC_URLS.TESTNET : TEMPO_RPC_URLS.MAINNET; +} + +/** + * Query TIP-20 balance via standard `balanceOf` eth_call. + */ +export async function queryTip20TokenBalance( + rpcUrl: string, + tokenContractAddress: string, + walletAddress: string +): Promise { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const iface = new ethers.utils.Interface(TIP20_ABI); + const data = iface.encodeFunctionData('balanceOf', [walletAddress]); + const result = await provider.call({ to: ethers.utils.getAddress(tokenContractAddress), data }); + const [bal] = iface.decodeFunctionResult('balanceOf', result); + return BigInt(bal.toString()); +} + +/** + * Pending nonce for an address (for AA / account tx ordering). + */ +export async function getTempoAddressNonce(rpcUrl: string, address: string): Promise { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + return provider.getTransactionCount(ethers.utils.getAddress(address), 'pending'); +} + const utils = { isValidAddress, isValidPublicKey, @@ -160,6 +191,9 @@ const utils = { isValidTip20Amount, isTip20Transaction, isValidMemoId, + getTempoRpcUrlForBaseChain, + queryTip20TokenBalance, + getTempoAddressNonce, }; export default utils; diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index 18d569ff25..ef5dfb741b 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -3,7 +3,9 @@ */ import { AbstractEthLikeNewCoins, + EIP1559, RecoverOptions, + RecoveryInfo, OfflineVaultTxInfo, UnsignedSweepTxMPCv2, TransactionBuilder, @@ -13,19 +15,40 @@ import { optionalDeps, } from '@bitgo/abstract-eth'; import type * as EthLikeCommon from '@ethereumjs/common'; +import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseCoin, BitGoBase, + common, + Ecdsa, + ECDSAUtils, + getIsUnsignedSweep, InvalidAddressError, InvalidMemoIdError, MPCAlgorithm, + MPCSweepRecoveryOptions, + MPCTx, + MPCTxs, ParseTransactionOptions, ParsedTransaction, + Recipient, + ReplayProtectionOptions, UnexpectedAddressError, + UnsignedTransactionTss, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; -import { Tip20Transaction, Tip20TransactionBuilder } from './lib'; -import { amountToTip20Units, isTip20Transaction, isValidMemoId as isValidMemoIdUtil } from './lib/utils'; +import { ethers } from 'ethers'; +import { KeyPair as KeyPairLib, Tip20Transaction, Tip20TransactionBuilder } from './lib'; +import type { TempoRecoveryOptions } from './lib/iface'; +import { + amountToTip20Units, + getTempoAddressNonce, + getTempoRpcUrlForBaseChain, + isTip20Transaction, + isValidMemoId as isValidMemoIdUtil, + queryTip20TokenBalance, + tip20UnitsToAmount, +} from './lib/utils'; import * as url from 'url'; import * as querystring from 'querystring'; @@ -281,27 +304,454 @@ export class Tempo extends AbstractEthLikeNewCoins { return true; } + /** @inheritdoc */ + async recover(params: RecoverOptions): Promise { + if (params.bitgoFeeAddress) { + throw new Error('EVM cross-chain recovery is not supported for Tempo'); + } + if (!params.isTss) { + throw new Error('Tempo recovery requires TSS (set isTss: true)'); + } + if (!this.resolveTokenContractAddressForRecovery(params)) { + throw new Error('tokenContractAddress is required for Tempo recovery - Tempo has no native asset'); + } + return super.recover(params); + } + /** - * Build unsigned sweep transaction for TSS - * TODO: Implement sweep transaction logic + * Resolves TIP-20 token contract for recovery. Subclasses (e.g. {@link Tip20Token}) may default this. */ - protected async buildUnsignedSweepTxnTSS(params: RecoverOptions): Promise { - // TODO: Implement when recovery logic is needed - // Return dummy value to prevent downstream services from breaking - return {} as OfflineVaultTxInfo; + protected resolveTokenContractAddressForRecovery(params: RecoverOptions): string | undefined { + const raw = params.tokenContractAddress?.replace(/\s/g, '').toLowerCase(); + return raw && raw.length > 0 ? raw : undefined; } /** - * Query block explorer for recovery information - * TODO: Implement when Tempo block explorer is available + * @inheritdoc + * Tempo TSS recovery sweeps TIP-20 via type 0x76 transactions (native balance is unused). + */ + protected async recoverTSS( + params: RecoverOptions + ): Promise { + this.validateRecoveryParams(params); + const userPublicOrPrivateKeyShare = params.userKey.replace(/\s/g, ''); + const backupPrivateOrPublicKeyShare = params.backupKey.replace(/\s/g, ''); + + if ( + getIsUnsignedSweep({ + userKey: userPublicOrPrivateKeyShare, + backupKey: backupPrivateOrPublicKeyShare, + isTss: params.isTss, + }) + ) { + return this.buildUnsignedSweepTxnTSS(params); + } + + this.assertTempoRecoveryEip1559(params); + const token = this.resolveTokenContractAddressForRecovery(params); + if (!token) { + throw new Error('tokenContractAddress is required for Tempo recovery - Tempo has no native asset'); + } + + const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares( + userPublicOrPrivateKeyShare, + backupPrivateOrPublicKeyShare, + params.walletPassphrase + ); + + const { gasLimit, maxFeePerGas, maxPriorityFeePerGas } = this.getTempoRecoveryGasValues(params); + const gasLimitBi = BigInt(gasLimit); + const maxCost = gasLimitBi * maxFeePerGas; + + const rpcUrl = this.resolveTempoRpcUrl(params); + const walletBase = this.getAddressDetails(params.walletContractAddress).baseAddress; + const feeToken = this.resolveFeeTokenAddress(params, token); + + const mpc = new Ecdsa(); + const derivedCommonKeyChain = mpc.deriveUnhardened(commonKeyChain, 'm/0'); + const derivedKeyPair = new KeyPairLib({ pub: derivedCommonKeyChain.slice(0, 66) }); + const derivedAddress = derivedKeyPair.getAddress(); + if (derivedAddress.toLowerCase() !== walletBase.toLowerCase()) { + throw new Error('walletContractAddress does not match derived TSS address'); + } + + const { sweepAmount } = await this.validateTip20SweepAmounts(rpcUrl, walletBase, token, feeToken, maxCost); + + const nonce = await getTempoAddressNonce(rpcUrl, walletBase); + const tip20Tx = await this.buildRecoveryTip20Transaction({ + tokenContractAddress: token, + feeTokenAddress: feeToken, + sweepAmount, + recoveryDestination: params.recoveryDestination, + nonce, + gasLimit: gasLimitBi, + maxFeePerGas, + maxPriorityFeePerGas, + }); + + const unsignedHex = await tip20Tx.serialize(); + const messageHash = Buffer.from(ethers.utils.arrayify(ethers.utils.keccak256(ethers.utils.arrayify(unsignedHex)))); + const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain); + tip20Tx.setSignature(this.mpcSignatureToTip20Sig(signature)); + + return { + id: tip20Tx.id, + tx: await tip20Tx.toBroadcastFormat(), + }; + } + + /** + * JSON-RPC backed queries for WRW (Etherscan-shaped responses for abstract-eth helpers). */ async recoveryBlockchainExplorerQuery( query: Record, apiKey?: string ): Promise> { - // TODO: Implement with Tempo block explorer API - // Return empty object to prevent downstream services from breaking - return {}; + const rpcUrl = this.resolveRpcUrlFromApiKey(apiKey); + + if (query.module === 'account' && query.action === 'tokenbalance') { + const bal = await queryTip20TokenBalance(rpcUrl, query.contractaddress, query.address); + return { result: bal.toString() }; + } + + if (query.module === 'account' && query.action === 'balance') { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const wei = await provider.getBalance(query.address, 'latest'); + return { result: wei.toString() }; + } + + if (query.module === 'account' && query.action === 'txlist') { + const nonce = await getTempoAddressNonce(rpcUrl, query.address); + return { nonce }; + } + + throw new Error(`Unsupported Tempo recovery query: ${query.module}/${query.action}`); + } + + /** + * @inheritdoc + * Unsigned WRW sweep: MPCv2 tx request with 0x76 serialized tx and keccak256(unsigned) as signable hex. + */ + protected async buildUnsignedSweepTxnTSS(params: RecoverOptions): Promise { + this.assertTempoRecoveryEip1559(params); + this.validateUnsignedSweepForMpcV2(params); + + const token = this.resolveTokenContractAddressForRecovery(params); + if (!token) { + throw new Error('tokenContractAddress is required for Tempo recovery - Tempo has no native asset'); + } + + const { gasLimit, maxFeePerGas, maxPriorityFeePerGas } = this.getTempoRecoveryGasValues(params); + const gasLimitBi = BigInt(gasLimit); + const maxCost = gasLimitBi * maxFeePerGas; + + const rpcUrl = this.resolveTempoRpcUrl(params); + const walletBase = this.getAddressDetails(params.walletContractAddress).baseAddress; + const feeToken = this.resolveFeeTokenAddress(params, token); + + const { sweepAmount } = await this.validateTip20SweepAmounts(rpcUrl, walletBase, token, feeToken, maxCost); + + const nonce = await getTempoAddressNonce(rpcUrl, walletBase); + const tip20Tx = await this.buildRecoveryTip20Transaction({ + tokenContractAddress: token, + feeTokenAddress: feeToken, + sweepAmount, + recoveryDestination: params.recoveryDestination, + nonce, + gasLimit: gasLimitBi, + maxFeePerGas, + maxPriorityFeePerGas, + }); + + const unsignedHex = await tip20Tx.serialize(); + const txInfo = { + recipient: { + address: params.recoveryDestination, + amount: sweepAmount.toString(), + } as Recipient, + expireTime: this.getDefaultExpireTime(), + gasLimit: gasLimit.toString(), + }; + + const backupKey = params.backupKey.replace(/\s/g, ''); + const derivationPath = params.derivationSeed ? getDerivationPath(params.derivationSeed) : 'm/0'; + + return this.buildTempoTxRequestForOfflineVaultMPCv2( + txInfo, + tip20Tx, + unsignedHex, + derivationPath, + nonce, + gasLimit, + params.eip1559!, + params.replayProtectionOptions, + backupKey + ); + } + + /** @inheritdoc */ + async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise { + const first = params.signatureShares[0]?.txRequest?.transactions?.[0]?.unsignedTx; + if (first && (first as any).coinSpecific?.tempoTip20Recovery === true) { + return this.createBroadcastableTempoTip20SweepTransaction(params); + } + return super.createBroadcastableSweepTransaction(params); + } + + private validateUnsignedSweepForMpcV2(params: RecoverOptions): void { + if (!params.backupKey?.replace(/\s/g, '')) { + throw new Error('missing commonKeyChain'); + } + if (params.derivationSeed !== undefined && typeof params.derivationSeed !== 'string') { + throw new Error('invalid derivationSeed'); + } + if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) { + throw new Error('missing or invalid destinationAddress'); + } + } + + private assertTempoRecoveryEip1559(params: RecoverOptions): asserts params is RecoverOptions & { + eip1559: EIP1559; + } { + if ( + params.eip1559 === undefined || + params.eip1559.maxFeePerGas === undefined || + params.eip1559.maxPriorityFeePerGas === undefined + ) { + throw new Error('eip1559.maxFeePerGas and eip1559.maxPriorityFeePerGas are required for Tempo recovery'); + } + } + + private getTempoRecoveryGasValues(params: RecoverOptions): { + gasLimit: number; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + } { + const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit)).toNumber(); + this.assertTempoRecoveryEip1559(params); + return { + gasLimit, + maxFeePerGas: BigInt(params.eip1559.maxFeePerGas), + maxPriorityFeePerGas: BigInt(params.eip1559.maxPriorityFeePerGas), + }; + } + + /** + * Default JSON-RPC URL: `common.Environments[env].evm.tempo|ttempo.rpcUrl`, then coin-local fallback. + * Statics network objects carry explorer links only, not RPC; final fallback uses `TEMPO_RPC_URLS` in `lib/constants`. + */ + private resolveDefaultRpcUrl(): string { + const evm = common.Environments[this.bitgo.getEnv()]?.evm; + const chainKey = this.getBaseChain() === 'ttempo' ? 'ttempo' : 'tempo'; + const rpcUrl = evm?.[chainKey]?.rpcUrl; + if (typeof rpcUrl === 'string' && rpcUrl.startsWith('http')) { + return rpcUrl.trim(); + } + return getTempoRpcUrlForBaseChain(this.getBaseChain()); + } + + private resolveRpcUrlFromApiKey(apiKey?: string): string { + if (apiKey?.startsWith('http')) { + return apiKey.trim(); + } + return this.resolveDefaultRpcUrl(); + } + + private resolveTempoRpcUrl(params: RecoverOptions): string { + const extra = params as TempoRecoveryOptions; + if (extra.rpcUrl?.startsWith('http')) { + return extra.rpcUrl.trim(); + } + return this.resolveRpcUrlFromApiKey(params.apiKey); + } + + private resolveFeeTokenAddress(params: RecoverOptions, tokenContractAddress: string): string { + const fee = (params as TempoRecoveryOptions).feeTokenAddress?.replace(/\s/g, '').toLowerCase(); + return fee && fee.length > 0 ? fee : tokenContractAddress; + } + + private async validateTip20SweepAmounts( + rpcUrl: string, + walletBase: string, + token: string, + feeToken: string, + maxCost: bigint + ): Promise<{ sweepAmount: bigint }> { + const tokenLc = token.toLowerCase(); + const feeLc = feeToken.toLowerCase(); + const tokenBalance = await queryTip20TokenBalance(rpcUrl, token, walletBase); + if (tokenBalance === 0n) { + throw new Error('Did not find address with funds to recover'); + } + + if (feeLc === tokenLc) { + if (tokenBalance <= maxCost) { + throw new Error('Not enough token funds to recover'); + } + return { sweepAmount: tokenBalance - maxCost }; + } + + const feeBal = await queryTip20TokenBalance(rpcUrl, feeToken, walletBase); + if (feeBal < maxCost) { + throw new Error('Not enough fee token balance to recover'); + } + return { sweepAmount: tokenBalance }; + } + + private async buildRecoveryTip20Transaction(args: { + tokenContractAddress: string; + feeTokenAddress: string; + sweepAmount: bigint; + recoveryDestination: string; + nonce: number; + gasLimit: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + }): Promise { + const { baseAddress: destBase, memoId } = this.getAddressDetails(args.recoveryDestination); + const amountStr = tip20UnitsToAmount(args.sweepAmount); + + const builder = new Tip20TransactionBuilder(coins.get(this.getBaseChain())); + builder.nonce(args.nonce); + builder.gas(args.gasLimit.toString()); + builder.maxFeePerGas(args.maxFeePerGas.toString()); + builder.maxPriorityFeePerGas(args.maxPriorityFeePerGas.toString()); + builder.feeToken(args.feeTokenAddress); + builder.addOperation({ + token: args.tokenContractAddress, + to: destBase, + amount: amountStr, + memo: memoId ?? undefined, + }); + + return (await builder.build()) as Tip20Transaction; + } + + private buildTempoTxRequestForOfflineVaultMPCv2( + txInfo: { recipient: Recipient; expireTime: number; gasLimit: string }, + _tip20Tx: Tip20Transaction, + unsignedHex: string, + derivationPath: string, + nonce: number, + gasLimit: number, + eip1559: EIP1559, + replayProtectionOptions: ReplayProtectionOptions | undefined, + commonKeyChain: string + ): UnsignedSweepTxMPCv2 { + const fee = gasLimit * eip1559.maxFeePerGas; + const signableHex = ethers.utils.keccak256(ethers.utils.arrayify(unsignedHex)).replace(/^0x/i, ''); + const serializedTxHex = unsignedHex.startsWith('0x') ? unsignedHex.slice(2) : unsignedHex; + + const unsignedTx: UnsignedTransactionTss = { + serializedTxHex, + signableHex, + derivationPath, + feeInfo: { + fee, + feeString: fee.toString(), + }, + parsedTx: { + spendAmount: txInfo.recipient.amount, + outputs: [ + { + coinName: this.getChain(), + address: txInfo.recipient.address, + valueString: txInfo.recipient.amount, + }, + ], + }, + coinSpecific: { + commonKeyChain, + tempoTip20Recovery: true, + }, + eip1559, + replayProtectionOptions, + }; + + return { + txRequests: [ + { + walletCoin: this.getChain(), + transactions: [ + { + unsignedTx, + nonce, + signatureShares: [], + }, + ], + }, + ], + }; + } + + private mpcSignatureToTip20Sig(signature: { recid: number; r: string; s: string }): { + r: `0x${string}`; + s: `0x${string}`; + yParity: number; + } { + const r = signature.r.startsWith('0x') ? signature.r : `0x${signature.r}`; + const s = signature.s.startsWith('0x') ? signature.s : `0x${signature.s}`; + return { r: r as `0x${string}`, s: s as `0x${string}`, yParity: signature.recid }; + } + + private async createBroadcastableTempoTip20SweepTransaction(params: MPCSweepRecoveryOptions): Promise { + const broadcastableTransactions: MPCTx[] = []; + let lastScanIndex = 0; + const req = params.signatureShares; + + for (let i = 0; i < req.length; i++) { + const transaction = req[i]?.txRequest?.transactions?.[0]?.unsignedTx; + if (!transaction) { + throw new Error(`Missing transaction at index ${i}`); + } + if (!req[i].ovc || !req[i].ovc[0].ecdsaSignature) { + throw new Error('Missing signature(s)'); + } + const serializedTxHex = (transaction as any).serializedTxHex as string | undefined; + const signableHex = (transaction as any).signableHex as string | undefined; + if (!serializedTxHex) { + throw new Error('Missing serialized transaction'); + } + if (!signableHex) { + throw new Error('Missing signable hex'); + } + const signature = req[i].ovc[0].ecdsaSignature; + if (!signature) { + throw new Error('Signature is undefined'); + } + const shares: string[] = signature.toString().split(':'); + if (shares.length !== 4) { + throw new Error('Invalid signature'); + } + const finalSig = { + recid: Number(shares[0]), + r: shares[1], + s: shares[2], + }; + + const rawHex = serializedTxHex.startsWith('0x') ? serializedTxHex : `0x${serializedTxHex}`; + + const txBuilder = this.getTransactionBuilder() as unknown as Tip20TransactionBuilder; + txBuilder.from(rawHex); + const tip20Tx = (await txBuilder.build()) as Tip20Transaction; + tip20Tx.setSignature(this.mpcSignatureToTip20Sig(finalSig)); + + const expectedSignable = ethers.utils.keccak256(ethers.utils.arrayify(rawHex)).replace(/^0x/i, ''); + if (expectedSignable.toLowerCase() !== signableHex.toLowerCase()) { + throw new Error('Signable hex does not match serialized Tempo transaction'); + } + + broadcastableTransactions.push({ + serializedTx: await tip20Tx.toBroadcastFormat(), + }); + + const coinSpecific = (transaction as any).coinSpecific as Record | undefined; + if (i === req.length - 1 && coinSpecific?.lastScanIndex !== undefined) { + lastScanIndex = coinSpecific.lastScanIndex as number; + } + } + + return { transactions: broadcastableTransactions, lastScanIndex }; } /** diff --git a/modules/sdk-coin-tempo/src/tip20Token.ts b/modules/sdk-coin-tempo/src/tip20Token.ts index 120ecc761d..338b33f43b 100644 --- a/modules/sdk-coin-tempo/src/tip20Token.ts +++ b/modules/sdk-coin-tempo/src/tip20Token.ts @@ -3,7 +3,7 @@ */ import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; import { coins, tokens, Tip20TokenConfig } from '@bitgo/statics'; -import { GetSendMethodArgsOptions, SendMethodArgs } from '@bitgo/abstract-eth'; +import { GetSendMethodArgsOptions, RecoverOptions, SendMethodArgs } from '@bitgo/abstract-eth'; import { Address } from './lib/types'; import { Tempo } from './tempo'; import { encodeTip20TransferWithMemo, amountToTip20Units, isValidAddress, isValidTip20Amount } from './lib/utils'; @@ -106,6 +106,15 @@ export class Tip20Token extends Tempo { return Math.pow(10, this.tokenConfig.decimalPlaces); } + /** @inheritdoc */ + protected resolveTokenContractAddressForRecovery(params: RecoverOptions): string | undefined { + const fromParams = super.resolveTokenContractAddressForRecovery(params); + if (fromParams) { + return fromParams; + } + return this.tokenContractAddress.replace(/\s/g, '').toLowerCase(); + } + /** @inheritDoc */ valuelessTransferAllowed(): boolean { return false; diff --git a/modules/sdk-coin-tempo/test/resources/tempo.ts b/modules/sdk-coin-tempo/test/resources/tempo.ts index 4736b56789..3ae55a1f97 100644 --- a/modules/sdk-coin-tempo/test/resources/tempo.ts +++ b/modules/sdk-coin-tempo/test/resources/tempo.ts @@ -28,6 +28,33 @@ export const TESTNET_TOKENS = { // Valid checksummed test recipient address export const TEST_RECIPIENT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; +// ============================================================================ +// Recovery Test Data +// ============================================================================ + +export const RECOVERY_TEST_DATA = { + // TSS public keys (ECDSA secp256k1 compressed format) + userPublicKey: '0x03abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + backupPublicKey: '0x02fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', + + // Wallet addresses + walletContractAddress: '0x2476602c78e9a5e0563320c78878faa3952b256f', + recoveryDestination: TEST_RECIPIENT_ADDRESS, + recoveryDestinationWithMemo: `${TEST_RECIPIENT_ADDRESS}?memoId=12345`, + + // Sample balances (in TIP-20 units - 6 decimals) + tokenBalance: BigInt(1000000), // 1.0 tokens + feeTokenBalance: BigInt(2000000), // 2.0 tokens + + // Gas parameters + gasLimit: 100000, + maxFeePerGas: 2000000000n, // 2 gwei + maxPriorityFeePerGas: 1000000000n, // 1 gwei + + // Expected sweep amount (balance - fees) + expectedSweepAmount: BigInt(800000), // 0.8 tokens after fees +}; + // ============================================================================ // Transaction Parameters // ============================================================================ diff --git a/modules/sdk-coin-tempo/test/unit/recovery.ts b/modules/sdk-coin-tempo/test/unit/recovery.ts new file mode 100644 index 0000000000..2128c88739 --- /dev/null +++ b/modules/sdk-coin-tempo/test/unit/recovery.ts @@ -0,0 +1,141 @@ +import { Tempo } from '../../src/tempo'; +import { Ttempo } from '../../src/ttempo'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoBase } from '@bitgo/sdk-core'; +import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import * as should from 'should'; +import * as sinon from 'sinon'; + +describe('Tempo Recovery', function () { + let bitgo: TestBitGoAPI; + let basecoin: Tempo; + let sandbox: sinon.SinonSandbox; + + const registerCoin = (name: string, coinClass: typeof Tempo | typeof Ttempo): void => { + bitgo.safeRegister(name, (bitgo: BitGoBase) => { + const mockStaticsCoin: Readonly = { + name, + fullName: name === 'tempo' ? 'Tempo' : 'Testnet Tempo', + network: { + type: name === 'tempo' ? 'mainnet' : 'testnet', + } as any, + features: ['tss'], + } as any; + return coinClass.createInstance(bitgo, mockStaticsCoin); + }); + }; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + registerCoin('tempo', Tempo); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tempo') as Tempo; + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('recover validation', function () { + it('should reject recovery without tokenContractAddress', async function () { + await basecoin + .recover({ + userKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + backupKey: '0x03abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + walletContractAddress: '0x2476602c78e9a5e0563320c78878faa3952b256f', + recoveryDestination: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + isTss: true, + eip1559: { + maxFeePerGas: 2000000000, + maxPriorityFeePerGas: 1000000000, + }, + }) + .should.be.rejectedWith(/tokenContractAddress is required/); + }); + + it('should reject recovery without isTss flag', async function () { + await basecoin + .recover({ + userKey: 'xprv...', + backupKey: 'xprv...', + walletContractAddress: '0x2476602c78e9a5e0563320c78878faa3952b256f', + recoveryDestination: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + tokenContractAddress: '0x20c0000000000000000000000000000000000000', + }) + .should.be.rejectedWith(/Tempo recovery requires TSS/); + }); + + it('should reject recovery with bitgoFeeAddress (cross-chain)', async function () { + await basecoin + .recover({ + userKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + backupKey: '0x03abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + walletContractAddress: '0x2476602c78e9a5e0563320c78878faa3952b256f', + recoveryDestination: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + tokenContractAddress: '0x20c0000000000000000000000000000000000000', + bitgoFeeAddress: '0x0000000000000000000000000000000000000001', + isTss: true, + }) + .should.be.rejectedWith(/cross-chain recovery is not supported/); + }); + }); + + describe('recoveryBlockchainExplorerQuery', function () { + it.skip('should query token balance via JSON-RPC', async function () { + // Skip: requires actual network mocking which is complex with ethers.js + // The implementation is tested through integration tests + }); + + it.skip('should query nonce via JSON-RPC', async function () { + // Skip: requires actual network mocking which is complex with ethers.js + // The implementation is tested through integration tests + }); + + it('should throw for unsupported query', async function () { + await basecoin + .recoveryBlockchainExplorerQuery({ + module: 'unknown', + action: 'unknown', + }) + .should.be.rejectedWith(/Unsupported Tempo recovery query/); + }); + }); + + describe('unsigned sweep validation', function () { + it('should require eip1559 params for unsigned sweep', async function () { + sandbox.stub(basecoin as any, 'validateTip20SweepAmounts').resolves({ sweepAmount: BigInt(1000000) }); + sandbox.stub(basecoin as any, 'resolveTempoRpcUrl').returns('https://rpc.testnet.tempo.xyz'); + + await (basecoin as any) + .buildUnsignedSweepTxnTSS({ + userKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + backupKey: '0x03abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + walletContractAddress: '0x2476602c78e9a5e0563320c78878faa3952b256f', + recoveryDestination: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + tokenContractAddress: '0x20c0000000000000000000000000000000000000', + isTss: true, + gasLimit: 100000, + }) + .should.be.rejectedWith(/eip1559.*required/); + }); + }); + + describe('address parsing with memoId', function () { + it('should parse memoId from recovery destination', async function () { + const details = basecoin.getAddressDetails('0x742d35Cc6634C0532925a3b844Bc454e4438f44e?memoId=12345'); + details.baseAddress.should.equal('0x742d35Cc6634C0532925a3b844Bc454e4438f44e'); + details.memoId!.should.equal('12345'); + }); + + it('should handle recovery destination without memoId', async function () { + const details = basecoin.getAddressDetails('0x742d35Cc6634C0532925a3b844Bc454e4438f44e'); + details.baseAddress.should.equal('0x742d35Cc6634C0532925a3b844Bc454e4438f44e'); + should.not.exist(details.memoId); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/environments.ts b/modules/sdk-core/src/bitgo/environments.ts index 8f73d92279..01d732a611 100644 --- a/modules/sdk-core/src/bitgo/environments.ts +++ b/modules/sdk-core/src/bitgo/environments.ts @@ -247,6 +247,14 @@ const mainnetBase: EnvironmentTemplate = { sonic: { baseUrl: 'https://api.etherscan.io/v2', }, + tempo: { + baseUrl: 'https://explore.mainnet.tempo.xyz/', + rpcUrl: 'https://rpc.mainnet.tempo.xyz', + }, + ttempo: { + baseUrl: 'https://explore.testnet.tempo.xyz/', + rpcUrl: 'https://rpc.testnet.tempo.xyz', + }, usdt0: { baseUrl: 'https://stablescan.xyz/api', }, @@ -460,6 +468,14 @@ const testnetBase: EnvironmentTemplate = { sonic: { baseUrl: 'https://api.etherscan.io/v2', }, + tempo: { + baseUrl: 'https://explore.mainnet.tempo.xyz/', + rpcUrl: 'https://rpc.mainnet.tempo.xyz', + }, + ttempo: { + baseUrl: 'https://explore.testnet.tempo.xyz/', + rpcUrl: 'https://rpc.testnet.tempo.xyz', + }, usdt0: { baseUrl: 'https://testnet.stablescan.xyz/api', },