From 3b14a96e844d049a14c32435b49f3c599f0783ce Mon Sep 17 00:00:00 2001 From: "B.Prasad" Date: Tue, 12 May 2026 14:44:43 +0530 Subject: [PATCH 1/2] feat: added recovery flow for tempo chain Signed-off-by: B.Prasad TICKET: CECHO-917 Signed-off-by: B.Prasad --- modules/sdk-coin-tempo/package.json | 1 + modules/sdk-coin-tempo/src/lib/constants.ts | 6 + modules/sdk-coin-tempo/src/tempo.ts | 251 ++++++++++++++++++-- modules/sdk-coin-tempo/test/unit/tempo.ts | 219 +++++++++++++++++ modules/sdk-core/src/bitgo/environments.ts | 6 + modules/statics/src/allCoinsAndTokens.ts | 63 ++++- 6 files changed, 520 insertions(+), 26 deletions(-) create mode 100644 modules/sdk-coin-tempo/test/unit/tempo.ts diff --git a/modules/sdk-coin-tempo/package.json b/modules/sdk-coin-tempo/package.json index d3ec1dca18..9abd5b04ae 100644 --- a/modules/sdk-coin-tempo/package.json +++ b/modules/sdk-coin-tempo/package.json @@ -42,6 +42,7 @@ "dependencies": { "@bitgo/abstract-eth": "^24.24.3", "@bitgo/sdk-core": "^36.44.0", + "@bitgo/sdk-lib-mpc": "^10.12.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.39.0", "@ethereumjs/common": "^2.6.5", diff --git a/modules/sdk-coin-tempo/src/lib/constants.ts b/modules/sdk-coin-tempo/src/lib/constants.ts index 3ee2ca9f53..7c0373a5c7 100644 --- a/modules/sdk-coin-tempo/src/lib/constants.ts +++ b/modules/sdk-coin-tempo/src/lib/constants.ts @@ -27,3 +27,9 @@ export const TIP20_DECIMALS = 6; * Tempo uses EIP-7702 Account Abstraction with transaction type 0x76 */ export const AA_TRANSACTION_TYPE = '0x76' as const; + +/** + * pathUSD — primary TIP-20 stablecoin on Tempo (6 decimals) + * Used as the default feeToken for all transactions. + */ +export const PATH_USD_ADDRESS = '0x20c0000000000000000000000000000000000000'; diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index fba6ee2dc0..51ca0c0ab2 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -6,11 +6,13 @@ import { RecoverOptions, OfflineVaultTxInfo, UnsignedSweepTxMPCv2, + RecoveryInfo, TransactionBuilder, VerifyEthTransactionOptions, VerifyEthAddressOptions, TssVerifyEthAddressOptions, optionalDeps, + KeyPair, } from '@bitgo/abstract-eth'; import type * as EthLikeCommon from '@ethereumjs/common'; import { @@ -24,10 +26,22 @@ import { UnexpectedAddressError, PopulatedIntent, PrebuildTransactionWithIntentOptions, + common, + Ecdsa, + ECDSAUtils, + getIsUnsignedSweep, } from '@bitgo/sdk-core'; +import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; +import { ethers } from 'ethers'; import { Tip20Transaction, Tip20TransactionBuilder } from './lib'; -import { amountToTip20Units, isTip20Transaction, isValidMemoId as isValidMemoIdUtil } from './lib/utils'; +import { + amountToTip20Units, + tip20UnitsToAmount, + isTip20Transaction, + isValidMemoId as isValidMemoIdUtil, +} from './lib/utils'; +import { PATH_USD_ADDRESS } from './lib/constants'; import * as url from 'url'; import * as querystring from 'querystring'; @@ -327,33 +341,236 @@ export class Tempo extends AbstractEthLikeNewCoins { } /** - * Build unsigned sweep transaction for TSS - * TODO: Implement sweep transaction logic + * Query the Tempo Alchemy RPC for recovery balance/nonce information. + * Routes through queryTempoRpc using the URL from environments.ts. */ - 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; + async recoveryBlockchainExplorerQuery( + query: Record, + apiKey?: string + ): Promise> { + const evmConfig = common.Environments[this.bitgo.getEnv()].evm; + const coinFamily = this.getFamily(); + if (!evmConfig || !(coinFamily in evmConfig)) { + throw new Error(`env config missing for ${coinFamily} in ${this.bitgo.getEnv()}`); + } + const token = apiKey || evmConfig[coinFamily].apiToken; + const rpcUrl = evmConfig[coinFamily].baseUrl; + return this.queryTempoRpc(query, rpcUrl, token); } /** - * Query block explorer for recovery information - * TODO: Implement when Tempo block explorer is available + * Translates Etherscan-style recovery queries into Tempo Alchemy JSON-RPC calls. + * + * Supported: + * account/balance → returns { result: '0' } (no native coin on Tempo) + * account/tokenbalance → eth_call (balanceOf selector 0x70a08231) → { result: decimalString } + * account/txlist → eth_getTransactionCount → { nonce: number } */ - async recoveryBlockchainExplorerQuery( + private async queryTempoRpc( query: Record, + rpcUrl: string, apiKey?: string ): Promise> { - // TODO: Implement with Tempo block explorer API - // Return empty object to prevent downstream services from breaking - return {}; + const endpoint = apiKey ? `${rpcUrl}${apiKey}` : rpcUrl; + const { module, action, address, contractaddress, tag } = query; + + let method: string; + let params: unknown[]; + + if (module === 'account' && action === 'balance') { + return { result: '0' }; + } else if (module === 'account' && action === 'tokenbalance') { + const paddedAddr = (address ?? '').replace(/^0x/, '').padStart(64, '0'); + method = 'eth_call'; + params = [{ to: contractaddress, data: '0x70a08231' + paddedAddr }, tag ?? 'latest']; + } else if (module === 'account' && action === 'txlist') { + method = 'eth_getTransactionCount'; + params = [address, 'latest']; + } else { + throw new Error(`queryTempoRpc: unsupported module=${module} action=${action}`); + } + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 }), + }); + + if (!res.ok) { + throw new Error(`Could not reach Tempo RPC endpoint (HTTP ${res.status})`); + } + + const body = (await res.json()) as { result?: unknown; error?: { message: string } }; + if (body.error) { + throw new Error(`Tempo RPC error: ${body.error.message}`); + } + + if (module === 'account' && action === 'txlist') { + return { nonce: parseInt(body.result as string, 16) }; + } + + try { + return { result: BigInt(body.result as string).toString() }; + } catch { + return { result: '0' }; + } + } + + /** + * Shared helper: queries the token balance, computes sweep amount, and builds + * an unsigned TIP-20 transfer transaction. Used by both recovery paths. + * + * - tokenContractAddress: the token to sweep (defaults to pathUSD) + * - feeToken: always pathUSD + * - If sweeping pathUSD itself, reserves gasLimit × maxFeePerGas / 10^12 pathUSD units for fees. + */ + private async buildTempoSweepTx( + walletAddress: string, + params: RecoverOptions + ): Promise<{ tx: Tip20Transaction; nonce: number; sweepAmount: bigint; gasMarginUnits: bigint }> { + const tokenAddress = params.tokenContractAddress ?? PATH_USD_ADDRESS; + + const rawBalance = await this.queryAddressTokenBalance(tokenAddress, walletAddress, params.apiKey); + const balance = BigInt(rawBalance.toString()); + + const isPathUsd = tokenAddress.toLowerCase() === PATH_USD_ADDRESS.toLowerCase(); + const gasLimitBig = BigInt(params.gasLimit ?? 700_000); + const maxFeePerGasBig = BigInt(params.eip1559?.maxFeePerGas ?? 20_000_000_000); + // 75% buffer on top of gasLimit × maxFeePerGas to cover actual on-chain gas variance + const gasMarginUnits = (gasLimitBig * maxFeePerGasBig * 175n) / (10n ** 12n * 100n); + const sweepAmount = isPathUsd ? balance - gasMarginUnits : balance; + + if (sweepAmount <= 0n) { + throw new Error( + `Insufficient balance in ${tokenAddress}: ${balance} units (minimum required: ${ + isPathUsd ? gasMarginUnits + 1n : 1n + })` + ); + } + + const sweepAmountHuman = tip20UnitsToAmount(sweepAmount); + const nonce = await this.getAddressNonce(walletAddress, params.apiKey); + + const txBuilder = this.getTransactionBuilder() as unknown as Tip20TransactionBuilder; + txBuilder + .addOperation({ token: tokenAddress, to: params.recoveryDestination, amount: sweepAmountHuman }) + .feeToken(PATH_USD_ADDRESS) + .nonce(nonce) + .gas(gasLimitBig) + .maxFeePerGas(maxFeePerGasBig) + .maxPriorityFeePerGas(BigInt(params.eip1559?.maxPriorityFeePerGas ?? 10_000_000_000)); + + const tx = (await txBuilder.build()) as Tip20Transaction; + return { tx, nonce, sweepAmount, gasMarginUnits }; + } + + /** + * Overrides the base-class recoverTSS to use TIP-20 transactions instead of standard ETH. + * + * Two paths: + * - Unsigned sweep (plain public key shares): delegates to buildUnsignedSweepTxnTSS + * - Signed sweep (encrypted keys + passphrase): builds and signs a TIP-20 tx via MPC + */ + protected async recoverTSS( + params: RecoverOptions + ): Promise { + this.validateRecoveryParams(params); + const userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + if (getIsUnsignedSweep({ userKey, backupKey, isTss: params.isTss })) { + return this.buildUnsignedSweepTxnTSS(params); + } + + // Signed sweep: decrypt MPC v2 key shares + const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares( + userKey, + backupKey, + params.walletPassphrase + ); + + const MPC = new Ecdsa(); + const derivedCommonKeyChain = MPC.deriveUnhardened(commonKeyChain, 'm/0'); + const backupKeyPair = new KeyPair({ pub: derivedCommonKeyChain.slice(0, 66) }); + const baseAddress = backupKeyPair.getAddress(); + + const { tx: unsignedTx } = await this.buildTempoSweepTx(baseAddress, params); + const serializedHex = await unsignedTx.serialize(); + + // Hash the unsigned 0x76 tx — matches Tip20TransactionBuilder's own signing logic + const msgHashHex = ethers.utils.keccak256(ethers.utils.arrayify(serializedHex)); + const messageHash = Buffer.from(msgHashHex.replace('0x', ''), 'hex'); + + const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain); + + // ECDSAMethodTypes.Signature.r/s are 64-char hex WITHOUT 0x prefix + unsignedTx.setSignature({ + r: `0x${signature.r}`, + s: `0x${signature.s}`, + yParity: signature.recid, + }); + + const signedHex = await unsignedTx.serialize(); + const txId = ethers.utils.keccak256(ethers.utils.arrayify(signedHex)); + return { id: txId, tx: signedHex }; + } + + /** + * Builds an unsigned TIP-20 sweep transaction for the offline vault (unsigned sweep path). + * Called by recoverTSS when plain public key shares are provided. + */ + protected async buildUnsignedSweepTxnTSS(params: RecoverOptions): Promise { + const backupKey = params.backupKey.replace(/\s/g, ''); + const derivationPath = params.derivationSeed ? getDerivationPath(params.derivationSeed) : 'm/0'; + const MPC = new Ecdsa(); + const derivedCommonKeyChain = MPC.deriveUnhardened(backupKey, derivationPath); + const backupKeyPair = new KeyPair({ pub: derivedCommonKeyChain.slice(0, 66) }); + const baseAddress = backupKeyPair.getAddress(); + + const { tx, nonce, sweepAmount, gasMarginUnits } = await this.buildTempoSweepTx(baseAddress, params); + const serializedHex = await tx.serialize(); + const serializedTxHex = serializedHex.replace('0x', ''); + const signableHex = ethers.utils.keccak256(ethers.utils.arrayify(serializedHex)).replace('0x', ''); + + return { + txRequests: [ + { + walletCoin: this.getChain(), + transactions: [ + { + unsignedTx: { + serializedTxHex, + signableHex, + derivationPath, + feeInfo: { + fee: Number(gasMarginUnits), + feeString: tip20UnitsToAmount(gasMarginUnits), + }, + parsedTx: { + spendAmount: tip20UnitsToAmount(sweepAmount), + outputs: [ + { + coinName: this.getChain(), + address: params.recoveryDestination, + valueString: tip20UnitsToAmount(sweepAmount), + }, + ], + }, + coinSpecific: { commonKeyChain: backupKey }, + eip1559: params.eip1559, + replayProtectionOptions: params.replayProtectionOptions, + }, + nonce, + signatureShares: [], + }, + ], + }, + ], + }; } /** - * Get transaction builder for Tempo - * Returns a TIP-20 transaction builder for Tempo-specific operations - * @param common - Optional common chain configuration - * @protected + * Get transaction builder for Tempo. */ protected getTransactionBuilder(common?: EthLikeCommon.default): TransactionBuilder { return new Tip20TransactionBuilder(coins.get(this.getBaseChain())) as unknown as TransactionBuilder; diff --git a/modules/sdk-coin-tempo/test/unit/tempo.ts b/modules/sdk-coin-tempo/test/unit/tempo.ts new file mode 100644 index 0000000000..d99821bef8 --- /dev/null +++ b/modules/sdk-coin-tempo/test/unit/tempo.ts @@ -0,0 +1,219 @@ +import sinon from 'sinon'; +import should from 'should'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoBase } from '@bitgo/sdk-core'; +import { BaseCoin as StaticsBaseCoin, CoinFamily } from '@bitgo/statics'; +import { Tempo } from '../../src/tempo'; +import { PATH_USD_ADDRESS } from '../../src/lib/constants'; + +// secp256k1 generator point G — a well-known valid compressed public key +const TEST_BACKUP_KEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; +const TEST_RECOVERY_DESTINATION = '0x80151ebf635e6ec8a5455258f617be6cda1fbd7e'; + +describe('Tempo Recovery', function () { + let bitgo: TestBitGoAPI; + let basecoin: Tempo; + let sandbox: sinon.SinonSandbox; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('tempo', (b: BitGoBase) => { + const mockStaticsCoin: Readonly = { + name: 'tempo', + fullName: 'Tempo', + family: CoinFamily.TEMPO, + network: { type: 'mainnet' }, + features: [], + } as any; + return Tempo.createInstance(b, mockStaticsCoin); + }); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tempo') as Tempo; + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('queryTempoRpc (via recoveryBlockchainExplorerQuery)', function () { + function mockFetch(responseBody: Record): sinon.SinonStub { + return sandbox.stub(global, 'fetch' as any).resolves({ + ok: true, + json: async () => responseBody, + } as any); + } + + it('returns { result: "0" } for account/balance without calling RPC', async function () { + const fetchStub = mockFetch({}); + const result = await basecoin.recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'balance', + address: '0xabc', + }); + result.should.deepEqual({ result: '0' }); + fetchStub.called.should.equal(false); + }); + + it('calls eth_call with balanceOf selector for account/tokenbalance', async function () { + const fetchStub = mockFetch({ result: '0x4e20' }); // 20000 decimal + const result = await basecoin.recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'tokenbalance', + address: '0x1234567890123456789012345678901234567890', + contractaddress: PATH_USD_ADDRESS, + tag: 'latest', + }); + result.should.deepEqual({ result: '20000' }); + const body = JSON.parse(fetchStub.firstCall.args[1].body); + body.method.should.equal('eth_call'); + body.params[0].data.should.startWith('0x70a08231'); + }); + + it('calls eth_getTransactionCount for account/txlist and parses nonce', async function () { + mockFetch({ result: '0x5' }); + const result = await basecoin.recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'txlist', + address: '0xabc', + }); + result.should.deepEqual({ nonce: 5 }); + }); + + it('returns { result: "0" } when RPC returns null body.result', async function () { + mockFetch({ result: null }); + const result = await basecoin.recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'tokenbalance', + address: '0xabc', + contractaddress: PATH_USD_ADDRESS, + }); + result.should.deepEqual({ result: '0' }); + }); + + it('returns { result: "0" } when RPC returns malformed body.result', async function () { + mockFetch({ result: 'not-a-number' }); + const result = await basecoin.recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'tokenbalance', + address: '0xabc', + contractaddress: PATH_USD_ADDRESS, + }); + result.should.deepEqual({ result: '0' }); + }); + + it('throws when the RPC returns an error field', async function () { + mockFetch({ error: { message: 'execution reverted' } }); + await basecoin + .recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'tokenbalance', + address: '0xabc', + contractaddress: PATH_USD_ADDRESS, + }) + .should.be.rejectedWith('Tempo RPC error: execution reverted'); + }); + + it('throws for unsupported module/action', async function () { + await basecoin + .recoveryBlockchainExplorerQuery({ module: 'proxy', action: 'eth_blockNumber' }) + .should.be.rejectedWith('queryTempoRpc: unsupported module=proxy action=eth_blockNumber'); + }); + }); + + describe('buildUnsignedSweepTxnTSS — gas margin calculation', function () { + function stubBalanceAndNonce(balance: string, nonce = 0) { + sandbox.stub(basecoin as any, 'queryAddressTokenBalance').resolves(balance); + sandbox.stub(basecoin as any, 'getAddressNonce').resolves(nonce); + } + + it('deducts gasLimit × maxFeePerGas / 10^12 from pathUSD balance', async function () { + // gasMargin = 1_000_000 * 20_000_000_000 / 10^12 = 20_000 + // sweepAmount = 1_000_000 - 20_000 = 980_000 + stubBalanceAndNonce('1000000'); + const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ + backupKey: TEST_BACKUP_KEY, + recoveryDestination: TEST_RECOVERY_DESTINATION, + gasLimit: 1_000_000, + eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, + })) as any; + should.exist(result.txRequests); + result.txRequests[0].transactions[0].unsignedTx.should.have.property('serializedTxHex'); + }); + + it('uses smaller margin when gasLimit is halved', async function () { + // gasMargin = 500_000 * 20_000_000_000 / 10^12 = 10_000 + // sweepAmount = 15_000 - 10_000 = 5_000 + stubBalanceAndNonce('15000'); + const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ + backupKey: TEST_BACKUP_KEY, + recoveryDestination: TEST_RECOVERY_DESTINATION, + gasLimit: 500_000, + eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, + })) as any; + should.exist(result.txRequests); + }); + + it('throws when balance exactly equals the gas margin', async function () { + // gasMargin = 1_000_000 * 20_000_000_000 / 10^12 = 20_000; balance = 20_000 → sweepAmount = 0 + stubBalanceAndNonce('20000'); + await (basecoin as any) + .buildUnsignedSweepTxnTSS({ + backupKey: TEST_BACKUP_KEY, + recoveryDestination: TEST_RECOVERY_DESTINATION, + gasLimit: 1_000_000, + eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, + }) + .should.be.rejectedWith(/Insufficient balance/); + }); + + it('does not deduct margin when sweeping a non-pathUSD token', async function () { + const otherToken = '0x1111111111111111111111111111111111111111'; + stubBalanceAndNonce('500'); + const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ + backupKey: TEST_BACKUP_KEY, + recoveryDestination: TEST_RECOVERY_DESTINATION, + tokenContractAddress: otherToken, + gasLimit: 1_000_000, + eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, + })) as any; + // Full balance of 500 swept; no margin deducted + should.exist(result.txRequests); + }); + + it('returns correct UnsignedSweepTxMPCv2 structure', async function () { + stubBalanceAndNonce('1000000'); + const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ + backupKey: TEST_BACKUP_KEY, + recoveryDestination: TEST_RECOVERY_DESTINATION, + gasLimit: 1_000_000, + eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, + })) as any; + const tx = result.txRequests[0].transactions[0]; + tx.unsignedTx.derivationPath.should.equal('m/0'); + tx.unsignedTx.coinSpecific.commonKeyChain.should.equal(TEST_BACKUP_KEY); + tx.signatureShares.should.deepEqual([]); + tx.nonce.should.equal(0); + }); + }); + + describe('recoverTSS — unsigned sweep dispatch', function () { + it('routes to buildUnsignedSweepTxnTSS when keys are plain public keys', async function () { + const buildStub = sandbox.stub(basecoin as any, 'buildUnsignedSweepTxnTSS').resolves({ txRequests: [] }); + sandbox.stub(basecoin as any, 'validateRecoveryParams').returns(undefined); + + await (basecoin as any).recoverTSS({ + userKey: TEST_BACKUP_KEY, + backupKey: TEST_BACKUP_KEY, + isTss: true, + recoveryDestination: TEST_RECOVERY_DESTINATION, + }); + + buildStub.calledOnce.should.equal(true); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/environments.ts b/modules/sdk-core/src/bitgo/environments.ts index 905a6e44a0..21507c099c 100644 --- a/modules/sdk-core/src/bitgo/environments.ts +++ b/modules/sdk-core/src/bitgo/environments.ts @@ -363,6 +363,9 @@ const mainnetBase: EnvironmentTemplate = { boba: { baseUrl: 'https://api.routescan.io/v2/network/mainnet/evm/288/etherscan/api', }, + tempo: { + baseUrl: 'https://tempo-mainnet.g.alchemy.com/v2/', + }, }, icpNodeUrl: 'https://ic0.app', kaspaNodeUrl: 'https://api.kaspa.org', @@ -588,6 +591,9 @@ const testnetBase: EnvironmentTemplate = { boba: { baseUrl: 'https://api.routescan.io/v2/network/testnet/evm/28882/etherscan/api', }, + tempo: { + baseUrl: 'https://tempo-moderato.g.alchemy.com/v2/', + }, }, stxNodeUrl: 'https://api.testnet.hiro.so', vetNodeUrl: 'https://sync-testnet.vechain.org', diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index a9a3612922..b888ce7c52 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -3350,7 +3350,12 @@ export const allCoinsAndTokens = [ 6, '0x20c0000000000000000000000000000000000000', UnderlyingAsset['tempo:pathusd'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), tip20Token( '39a57b34-0ce5-40d6-a231-c53a287491a6', @@ -3359,7 +3364,12 @@ export const allCoinsAndTokens = [ 6, '0x20c000000000000000000000b9537d11c60e8b50', UnderlyingAsset['tempo:usdc'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), tip20Token( 'fa9e874b-e1c0-4c40-ab4c-3cfba7b2ca8b', @@ -3368,7 +3378,12 @@ export const allCoinsAndTokens = [ 6, '0x20c000000000000000000000111111111e910f0f', UnderlyingAsset['tempo:usd1'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), // Tempo TIP20 testnet tokens ttip20Token( @@ -3378,7 +3393,12 @@ export const allCoinsAndTokens = [ 6, '0x20c0000000000000000000000000000000000000', UnderlyingAsset['ttempo:pathusd'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), ttip20Token( '3c67eaa8-f073-4e1a-9d3a-c6756a31bef0', @@ -3387,7 +3407,12 @@ export const allCoinsAndTokens = [ 6, '0x20c0000000000000000000000000000000000001', UnderlyingAsset['ttempo:alphausd'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), ttip20Token( 'da6d27bd-ed3b-4b59-b574-6e013e5eb55d', @@ -3396,7 +3421,12 @@ export const allCoinsAndTokens = [ 6, '0x20c0000000000000000000000000000000000002', UnderlyingAsset['ttempo:betausd'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), ttip20Token( '58cbb592-446e-4753-8c2a-c89f662135ba', @@ -3405,7 +3435,12 @@ export const allCoinsAndTokens = [ 6, '0x20c0000000000000000000000000000000000003', UnderlyingAsset['ttempo:thetausd'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), ttip20Token( '1b9cb8c9-6eec-4f0a-acd4-3d1881efc85b', @@ -3414,7 +3449,12 @@ export const allCoinsAndTokens = [ 6, '0x20c00000000000000000000008bb598f4db17f78', UnderlyingAsset['ttempo:usd1'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), ttip20Token( 'b6455ffa-8732-4c61-bb2b-d72e72cb1e63', @@ -3423,7 +3463,12 @@ export const allCoinsAndTokens = [ 6, '0x20c000000000000000000000e4662b69291ab60a', UnderlyingAsset['ttempo:stgusd1'], - [...TEMPO_FEATURES, CoinFeature.STABLECOIN] + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] ), canton( '07385320-5a4f-48e9-97a5-86d4be9f24b0', From 81a02d6601327fd402fb1673eef743fb1f3a3c30 Mon Sep 17 00:00:00 2001 From: "B.Prasad" Date: Wed, 13 May 2026 17:27:49 +0530 Subject: [PATCH 2/2] fix(sdk-coin-tempo): update test fixtures for deriveUnhardened and 75% gas buffer TEST_BACKUP_KEY updated to a full 130-char commonKeyChain (pubkey + chaincode) since buildUnsignedSweepTxnTSS now calls MPC.deriveUnhardened which requires both components. Test 2 balance raised from 15_000 to 50_000 to account for the 75% gas margin buffer raising the minimum required balance to 17_500. Co-Authored-By: Claude Sonnet 4.6 TICKET: CECHO-917 Signed-off-by: B.Prasad --- modules/sdk-coin-tempo/src/lib/constants.ts | 8 +++----- modules/sdk-coin-tempo/src/tempo.ts | 14 +++++++++---- modules/sdk-coin-tempo/test/unit/tempo.ts | 22 +++++++++++++-------- modules/statics/src/account.ts | 4 +--- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/modules/sdk-coin-tempo/src/lib/constants.ts b/modules/sdk-coin-tempo/src/lib/constants.ts index 7c0373a5c7..7cd020e4f0 100644 --- a/modules/sdk-coin-tempo/src/lib/constants.ts +++ b/modules/sdk-coin-tempo/src/lib/constants.ts @@ -28,8 +28,6 @@ export const TIP20_DECIMALS = 6; */ export const AA_TRANSACTION_TYPE = '0x76' as const; -/** - * pathUSD — primary TIP-20 stablecoin on Tempo (6 decimals) - * Used as the default feeToken for all transactions. - */ -export const PATH_USD_ADDRESS = '0x20c0000000000000000000000000000000000000'; +/** TIP-20 token names for pathUSD — used to look up contract address from statics */ +export const PATH_USD_TOKEN_MAINNET = 'tempo:pathusd'; +export const PATH_USD_TOKEN_TESTNET = 'ttempo:pathusd'; diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index 51ca0c0ab2..685163a93b 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -41,7 +41,7 @@ import { isTip20Transaction, isValidMemoId as isValidMemoIdUtil, } from './lib/utils'; -import { PATH_USD_ADDRESS } from './lib/constants'; +import { MAINNET_COIN, PATH_USD_TOKEN_MAINNET, PATH_USD_TOKEN_TESTNET } from './lib/constants'; import * as url from 'url'; import * as querystring from 'querystring'; @@ -428,12 +428,18 @@ export class Tempo extends AbstractEthLikeNewCoins { walletAddress: string, params: RecoverOptions ): Promise<{ tx: Tip20Transaction; nonce: number; sweepAmount: bigint; gasMarginUnits: bigint }> { - const tokenAddress = params.tokenContractAddress ?? PATH_USD_ADDRESS; + if (!params.tokenContractAddress) { + throw new Error('tokenContractAddress is required for sweep'); + } + const tokenAddress = params.tokenContractAddress; + + const pathUsdTokenName = this.getChain() === MAINNET_COIN ? PATH_USD_TOKEN_MAINNET : PATH_USD_TOKEN_TESTNET; + const pathUsdAddress = (coins.get(pathUsdTokenName) as unknown as { contractAddress: string }).contractAddress; const rawBalance = await this.queryAddressTokenBalance(tokenAddress, walletAddress, params.apiKey); const balance = BigInt(rawBalance.toString()); - const isPathUsd = tokenAddress.toLowerCase() === PATH_USD_ADDRESS.toLowerCase(); + const isPathUsd = tokenAddress.toLowerCase() === pathUsdAddress.toLowerCase(); const gasLimitBig = BigInt(params.gasLimit ?? 700_000); const maxFeePerGasBig = BigInt(params.eip1559?.maxFeePerGas ?? 20_000_000_000); // 75% buffer on top of gasLimit × maxFeePerGas to cover actual on-chain gas variance @@ -454,7 +460,7 @@ export class Tempo extends AbstractEthLikeNewCoins { const txBuilder = this.getTransactionBuilder() as unknown as Tip20TransactionBuilder; txBuilder .addOperation({ token: tokenAddress, to: params.recoveryDestination, amount: sweepAmountHuman }) - .feeToken(PATH_USD_ADDRESS) + .feeToken(pathUsdAddress) .nonce(nonce) .gas(gasLimitBig) .maxFeePerGas(maxFeePerGasBig) diff --git a/modules/sdk-coin-tempo/test/unit/tempo.ts b/modules/sdk-coin-tempo/test/unit/tempo.ts index d99821bef8..8aa88448ae 100644 --- a/modules/sdk-coin-tempo/test/unit/tempo.ts +++ b/modules/sdk-coin-tempo/test/unit/tempo.ts @@ -5,10 +5,12 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoBase } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, CoinFamily } from '@bitgo/statics'; import { Tempo } from '../../src/tempo'; -import { PATH_USD_ADDRESS } from '../../src/lib/constants'; +const PATH_USD_ADDRESS = '0x20c0000000000000000000000000000000000000'; -// secp256k1 generator point G — a well-known valid compressed public key -const TEST_BACKUP_KEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; +// secp256k1 generator point G (33 bytes) + 32-byte chaincode — valid commonKeyChain for deriveUnhardened +const TEST_BACKUP_KEY = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + + '0000000000000000000000000000000000000000000000000000000000000001'; const TEST_RECOVERY_DESTINATION = '0x80151ebf635e6ec8a5455258f617be6cda1fbd7e'; describe('Tempo Recovery', function () { @@ -132,12 +134,13 @@ describe('Tempo Recovery', function () { } it('deducts gasLimit × maxFeePerGas / 10^12 from pathUSD balance', async function () { - // gasMargin = 1_000_000 * 20_000_000_000 / 10^12 = 20_000 - // sweepAmount = 1_000_000 - 20_000 = 980_000 + // gasMargin = 1_000_000 * 20_000_000_000 * 1.75 / 10^12 = 35_000 + // sweepAmount = 1_000_000 - 35_000 = 965_000 stubBalanceAndNonce('1000000'); const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ backupKey: TEST_BACKUP_KEY, recoveryDestination: TEST_RECOVERY_DESTINATION, + tokenContractAddress: PATH_USD_ADDRESS, gasLimit: 1_000_000, eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, })) as any; @@ -146,12 +149,13 @@ describe('Tempo Recovery', function () { }); it('uses smaller margin when gasLimit is halved', async function () { - // gasMargin = 500_000 * 20_000_000_000 / 10^12 = 10_000 - // sweepAmount = 15_000 - 10_000 = 5_000 - stubBalanceAndNonce('15000'); + // gasMargin = 500_000 * 20_000_000_000 * 1.75 / 10^12 = 17_500 + // sweepAmount = 50_000 - 17_500 = 32_500 + stubBalanceAndNonce('50000'); const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ backupKey: TEST_BACKUP_KEY, recoveryDestination: TEST_RECOVERY_DESTINATION, + tokenContractAddress: PATH_USD_ADDRESS, gasLimit: 500_000, eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, })) as any; @@ -165,6 +169,7 @@ describe('Tempo Recovery', function () { .buildUnsignedSweepTxnTSS({ backupKey: TEST_BACKUP_KEY, recoveryDestination: TEST_RECOVERY_DESTINATION, + tokenContractAddress: PATH_USD_ADDRESS, gasLimit: 1_000_000, eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, }) @@ -190,6 +195,7 @@ describe('Tempo Recovery', function () { const result = (await (basecoin as any).buildUnsignedSweepTxnTSS({ backupKey: TEST_BACKUP_KEY, recoveryDestination: TEST_RECOVERY_DESTINATION, + tokenContractAddress: PATH_USD_ADDRESS, gasLimit: 1_000_000, eip1559: { maxFeePerGas: 20_000_000_000, maxPriorityFeePerGas: 10_000_000_000 }, })) as any; diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index 7adfb776eb..a6f758d2be 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -825,13 +825,11 @@ export class CantonToken extends AccountCoinToken { * The Tempo network supports TIP20 tokens * TIP20 tokens are ERC20-compatible tokens on the Tempo network */ -export class Tip20Token extends AccountCoinToken { - public contractAddress: string; +export class Tip20Token extends ContractAddressDefinedToken { constructor(options: Tip20TokenConstructorOptions) { super({ ...options, }); - this.contractAddress = options.contractAddress; } }