diff --git a/ironfish-cli/src/commands/wallet/chainport/send.ts b/ironfish-cli/src/commands/wallet/chainport/send.ts index b74437911b..4585159624 100644 --- a/ironfish-cli/src/commands/wallet/chainport/send.ts +++ b/ironfish-cli/src/commands/wallet/chainport/send.ts @@ -320,6 +320,10 @@ export class BridgeCommand extends IronfishCommand { expiration: number | undefined, ) { const { flags } = await this.parse(BridgeCommand) + const response = await client.wallet.getAccountPublicKey({ + account: from, + }) + const sourceAddress = response.content.publicKey ux.action.start('Fetching bridge transaction details') const txn = await fetchChainportBridgeTransaction( @@ -328,24 +332,39 @@ export class BridgeCommand extends IronfishCommand { asset.web3_address, tokenWithNetwork.network.chainport_network_id, to, + sourceAddress, ) ux.action.stop() + const userOutput = { + publicAddress: txn.bridge_output.publicAddress, + amount: txn.bridge_output.amount, + memoHex: txn.bridge_output.memoHex, + assetId: txn.bridge_output.assetId, + } + const gasOutput = { + publicAddress: txn.gas_fee_output.publicAddress, + amount: txn.gas_fee_output.amount, + memo: txn.gas_fee_output.memo, + } + + const outputs = [userOutput, gasOutput] + + const bridgeFeeAmount = BigInt(txn.bridge_fee.source_token_fee_amount) + if (bridgeFeeAmount > 0n) { + userOutput.amount = (BigInt(userOutput.amount) - bridgeFeeAmount).toString() + const bridgeFeeOutput = { + publicAddress: txn.bridge_fee.publicAddress, + amount: bridgeFeeAmount.toString(), + memo: txn.bridge_fee.memo, + assetId: txn.bridge_fee.assetId, + } + outputs.push(bridgeFeeOutput) + } + const params: CreateTransactionRequest = { account: from, - outputs: [ - { - publicAddress: txn.bridge_output.publicAddress, - amount: txn.bridge_output.amount, - memoHex: txn.bridge_output.memoHex, - assetId: txn.bridge_output.assetId, - }, - { - publicAddress: txn.gas_fee_output.publicAddress, - amount: txn.gas_fee_output.amount, - memo: txn.gas_fee_output.memo, - }, - ], + outputs, fee: flags.fee ? CurrencyUtils.encode(flags.fee) : null, feeRate: flags.feeRate ? CurrencyUtils.encode(flags.feeRate) : null, expiration, @@ -406,31 +425,15 @@ export class BridgeCommand extends IronfishCommand { const targetNetworkFee = CurrencyUtils.render(BigInt(txn.gas_fee_output.amount), true) - let chainportFee: string - - if (txn.bridge_fee.is_portx_fee_payment) { - this.logger.log('\nStaked PortX detected') - - chainportFee = CurrencyUtils.render( - BigInt(txn.bridge_fee.portx_fee_amount), - true, - 'portx asset id', - { - decimals: 18, - symbol: 'PORTX', - }, - ) - } else { - chainportFee = CurrencyUtils.render( - BigInt(txn.bridge_fee.source_token_fee_amount ?? 0), - true, - 'chainport fee id', - { - decimals: sourceToken.decimals, - symbol: sourceAsset.verification.symbol, - }, - ) - } + const chainportFee = CurrencyUtils.render( + BigInt(txn.bridge_fee.source_token_fee_amount ?? 0), + true, + 'chainport fee id', + { + decimals: sourceToken.decimals, + symbol: sourceAsset.verification.symbol, + }, + ) const summary = `\ \nBRIDGE TRANSACTION SUMMARY: diff --git a/ironfish-cli/src/utils/chainport/metadata.test.ts b/ironfish-cli/src/utils/chainport/metadata.test.ts index f0bf51ceab..55660a6e28 100644 --- a/ironfish-cli/src/utils/chainport/metadata.test.ts +++ b/ironfish-cli/src/utils/chainport/metadata.test.ts @@ -48,9 +48,15 @@ describe('ChainportMemoMetadata', () => { '02161ca1882c130f04f04638f2092851863c518018c0012ca1093c438b10200a', ), ).toEqual([22, '0x7A68B1Cf1F16Ef89A566F5606C01BA49F4Eb420A'.toLowerCase(), true]) + + expect( + ChainportMemoMetadata.decode( + '004f99a1a130db7faf2d00d729ad1fc41c76547c5646d10f28e0000000000000', + ), + ).toEqual([15, '0x99A1a130DB7FAf2d00d729aD1FC41c76547c5646'.toLowerCase(), false]) }) - test('encode and decode are reversible', () => { + test('encode and decode are reversible v1', () => { const networkId = 2 const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a' const toIronfish = false @@ -60,4 +66,91 @@ describe('ChainportMemoMetadata', () => { expect(decoded).toEqual([networkId, '0x' + address.toLowerCase(), toIronfish]) }) + + test('encode and decode are reversible v2', () => { + const networkId = 2 + const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a' + const toIronfish = false + const timestamp = 1753715824 + const version = 1 + + const encoded = ChainportMemoMetadata.encodeV2( + networkId, + address, + toIronfish, + timestamp, + version, + ) + const decoded = ChainportMemoMetadata.decode(encoded) + + expect(decoded).toEqual([networkId, '0x' + address.toLowerCase(), toIronfish]) + }) + + test('should throw error if networkId is greater than 63', () => { + const networkId = 64 + const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a' + const toIronfish = false + const timestamp = 1753715824 + const version = 1 + + expect(() => + ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version), + ).toThrow('networkId exceeds 6-bit capacity') + }) + + test('should throw error if version is greater than 3', () => { + const networkId = 2 + const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a' + const toIronfish = false + const timestamp = 1753715824 + const version = 4 + + expect(() => + ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version), + ).toThrow('version exceeds 2-bit capacity') + }) + + test('should throw error if timestamp is greater than 2147483647', () => { + const networkId = 2 + const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a' + const toIronfish = false + const timestamp = 2147483648 + const version = 1 + + expect(() => + ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version), + ).toThrow('timestamp exceeds 31-bit capacity') + }) + + test('should throw error if address is not 40 hexadecimal characters', () => { + const networkId = 2 + const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9atest' + const toIronfish = false + const timestamp = 1753715824 + const version = 1 + + expect(() => + ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version), + ).toThrow('address must be 40 hexadecimal characters') + }) + + test('should throw error if memoHex version is not 1 for decodeV2', () => { + const networkId = 2 + const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a' + const toIronfish = false + const timestamp = 1753715824 + const version = 2 + + const encoded = ChainportMemoMetadata.encodeV2( + networkId, + address, + toIronfish, + timestamp, + version, + ) + + expect(() => ChainportMemoMetadata.decodeV2(encoded)).toThrow( + 'Unexpected memoHex version: 10', + ) + }) }) diff --git a/ironfish-cli/src/utils/chainport/metadata.ts b/ironfish-cli/src/utils/chainport/metadata.ts index 4126fc9dce..06c42180f0 100644 --- a/ironfish-cli/src/utils/chainport/metadata.ts +++ b/ironfish-cli/src/utils/chainport/metadata.ts @@ -44,6 +44,15 @@ export class ChainportMemoMetadata { return String.fromCharCode(num - 10 + 'a'.charCodeAt(0)) } + public static convertHexToBinary(encodedHex: string): string { + const buffer = Buffer.from(encodedHex, 'hex') + let binaryString = '' + for (let i = 0; i < buffer.length; i++) { + binaryString += buffer[i].toString(2).padStart(8, '0') + } + return binaryString + } + public static encode(networkId: number, address: string, toIronfish: boolean) { if (address.startsWith('0x')) { address = address.slice(2) @@ -63,7 +72,86 @@ export class ChainportMemoMetadata { return hexString.padStart(64, '0') } - public static decode(encodedHex: string): [number, string, boolean] { + public static encodeV2( + networkId: number, + address: string, + toIronfish: boolean, + timestamp: number, + version: number, + ) { + if (networkId >= 1 << 6) { + throw new Error('networkId exceeds 6-bit capacity') + } + if (version >= 1 << 2) { + throw new Error('version exceeds 2-bit capacity') + } + if (BigInt(timestamp) >= 1n << 31n) { + throw new Error('timestamp exceeds 31-bit capacity') + } + + let addressClean = address + if (addressClean.startsWith('0x')) { + addressClean = addressClean.slice(2) + } + + if (addressClean.length !== 40) { + throw new Error('address must be 40 hexadecimal characters') + } + + const addrBytes = Buffer.from(addressClean, 'hex') + + if (addrBytes.length !== 20) { + throw new Error('address must decode to 20 bytes') + } + + const bitArray: number[] = new Array(256).fill(0) as number[] + let pos = 0 + + pos += 6 + + bitArray[pos] = toIronfish ? 1 : 0 + pos += 1 + + pos += 1 + + bitArray[pos] = (version >> 1) & 1 + bitArray[pos + 1] = version & 1 + pos += 2 + + for (let i = 0; i < 6; i++) { + bitArray[pos + i] = (networkId >> (5 - i)) & 1 + } + pos += 6 + + for (const byte of addrBytes) { + for (let i = 0; i < 8; i++) { + bitArray[pos] = (byte >> (7 - i)) & 1 + pos += 1 + } + } + + for (let i = 0; i < 31; i++) { + bitArray[pos + i] = (timestamp >> (30 - i)) & 1 + } + pos += 31 + + pos += 49 + + const result = new Uint8Array(32) + for (let i = 0; i < 32; i++) { + let byte = 0 + for (let j = 0; j < 8; j++) { + byte = (byte << 1) | bitArray[i * 8 + j] + } + result[i] = byte + } + + return Array.from(result) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + } + + public static decodeV1(encodedHex: string): [number, string, boolean] { const hexInteger = BigInt('0x' + encodedHex) const encodedString = hexInteger.toString(2) const padded = encodedString.padStart(250, '0') @@ -82,4 +170,32 @@ export class ChainportMemoMetadata { return [networkId, address.toLowerCase(), toIronfish] } + + public static decodeV2(encodedHex: string): [number, string, boolean] { + const bits = this.convertHexToBinary(encodedHex) + const toIronfish = bits[6] === '1' + const memoHexVersion = bits.slice(8, 10) + if (memoHexVersion !== '01') { + throw new Error(`Unexpected memoHex version: ${memoHexVersion}`) + } + + const networkIdBits = bits.slice(10, 16) + const networkId = parseInt(networkIdBits, 2) + const addressBits = bits.slice(16, 176) + let address = '0x' + for (let i = 0; i < addressBits.length; i += 4) { + address += parseInt(addressBits.slice(i, i + 4), 2).toString(16) + } + + return [networkId, address.toLowerCase(), toIronfish] + } + + public static decode(encodedHex: string): [number, string, boolean] { + const bits = this.convertHexToBinary(encodedHex) + const memoHexVersion = bits.slice(8, 10) + if (memoHexVersion === '01') { + return this.decodeV2(encodedHex) + } + return this.decodeV1(encodedHex) + } } diff --git a/ironfish-cli/src/utils/chainport/requests.ts b/ironfish-cli/src/utils/chainport/requests.ts index b750940550..b38831dbd4 100644 --- a/ironfish-cli/src/utils/chainport/requests.ts +++ b/ironfish-cli/src/utils/chainport/requests.ts @@ -57,6 +57,7 @@ export const fetchChainportBridgeTransaction = async ( assetId: string, targetNetworkId: number, targetAddress: string, + sourceAddress: string, ): Promise => { const config = getConfig(networkId) const url = new URL(`/bridges/transactions/create`, config.endpoint) @@ -64,6 +65,7 @@ export const fetchChainportBridgeTransaction = async ( url.searchParams.append('asset_id', assetId) url.searchParams.append('target_network_id', targetNetworkId.toString()) url.searchParams.append('target_address', targetAddress.toString()) + url.searchParams.append('source_address', sourceAddress) return await makeChainportRequest(url.toString()) } diff --git a/ironfish-cli/src/utils/chainport/types.ts b/ironfish-cli/src/utils/chainport/types.ts index af3b71beba..f3a408111c 100644 --- a/ironfish-cli/src/utils/chainport/types.ts +++ b/ironfish-cli/src/utils/chainport/types.ts @@ -4,6 +4,19 @@ // This file contains response types for chainport requests +export type ChainportBridgeFeeV1 = { + source_token_fee_amount: string + portx_fee_amount: string + is_portx_fee_payment: boolean +} + +export type ChainportBridgeFeeV2 = { + publicAddress: string + source_token_fee_amount: string + memo: string + assetId: string +} + export type ChainportBridgeTransaction = { bridge_output: { publicAddress: string @@ -16,11 +29,7 @@ export type ChainportBridgeTransaction = { amount: string memo: string } - bridge_fee: { - source_token_fee_amount: string - portx_fee_amount: string - is_portx_fee_payment: boolean - } + bridge_fee: ChainportBridgeFeeV2 } export type ChainportNetwork = { diff --git a/ironfish-cli/src/utils/chainport/utils.test.ts b/ironfish-cli/src/utils/chainport/utils.test.ts index 74ef594fef..4c038babb3 100644 --- a/ironfish-cli/src/utils/chainport/utils.test.ts +++ b/ironfish-cli/src/utils/chainport/utils.test.ts @@ -92,7 +92,25 @@ describe('isChainportTransaction', () => { expect(result).not.toBeDefined() }) - it('should return true for valid outgoing chainport transaction', () => { + it('should return true for valid outgoing chainport transaction with bridge fee v1', () => { + ;(ChainportMemoMetadata.decode as Mock).mockReturnValue([1, 'address']) + const transaction = { + type: TransactionType.SEND, + notes: [ + { owner: 'outgoing1', memo: '{"type": "fee_payment"}', memoHex: 'mockHex' }, + { owner: 'outgoing1', memo: '', memoHex: 'mockHex' }, + ], + } as RpcWalletTransaction + const result = extractChainportDataFromTransaction(1, transaction) + expect(result).toBeDefined() + expect(result).toEqual({ + type: TransactionType.SEND, + chainportNetworkId: 1, + address: 'address', + }) + }) + + it('should return true for valid outgoing chainport transaction with bridge fee v2', () => { ;(ChainportMemoMetadata.decode as Mock).mockReturnValue([1, 'address']) const transaction = { type: TransactionType.SEND,