Skip to content

Commit f2d1f07

Browse files
authored
feat: chainport bridge fee upgrade (#5755)
* feat: support chainport bridge fee upgrade * feat: update memoHex decoding v1 v2 * feat: update bridge memoHex encoding/decoding and remove upgrade date config * test: add tests for metadata encodeV2 and decodeV2
1 parent ec50882 commit f2d1f07

File tree

6 files changed

+287
-46
lines changed

6 files changed

+287
-46
lines changed

ironfish-cli/src/commands/wallet/chainport/send.ts

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ export class BridgeCommand extends IronfishCommand {
320320
expiration: number | undefined,
321321
) {
322322
const { flags } = await this.parse(BridgeCommand)
323+
const response = await client.wallet.getAccountPublicKey({
324+
account: from,
325+
})
326+
const sourceAddress = response.content.publicKey
323327

324328
ux.action.start('Fetching bridge transaction details')
325329
const txn = await fetchChainportBridgeTransaction(
@@ -328,24 +332,39 @@ export class BridgeCommand extends IronfishCommand {
328332
asset.web3_address,
329333
tokenWithNetwork.network.chainport_network_id,
330334
to,
335+
sourceAddress,
331336
)
332337
ux.action.stop()
333338

339+
const userOutput = {
340+
publicAddress: txn.bridge_output.publicAddress,
341+
amount: txn.bridge_output.amount,
342+
memoHex: txn.bridge_output.memoHex,
343+
assetId: txn.bridge_output.assetId,
344+
}
345+
const gasOutput = {
346+
publicAddress: txn.gas_fee_output.publicAddress,
347+
amount: txn.gas_fee_output.amount,
348+
memo: txn.gas_fee_output.memo,
349+
}
350+
351+
const outputs = [userOutput, gasOutput]
352+
353+
const bridgeFeeAmount = BigInt(txn.bridge_fee.source_token_fee_amount)
354+
if (bridgeFeeAmount > 0n) {
355+
userOutput.amount = (BigInt(userOutput.amount) - bridgeFeeAmount).toString()
356+
const bridgeFeeOutput = {
357+
publicAddress: txn.bridge_fee.publicAddress,
358+
amount: bridgeFeeAmount.toString(),
359+
memo: txn.bridge_fee.memo,
360+
assetId: txn.bridge_fee.assetId,
361+
}
362+
outputs.push(bridgeFeeOutput)
363+
}
364+
334365
const params: CreateTransactionRequest = {
335366
account: from,
336-
outputs: [
337-
{
338-
publicAddress: txn.bridge_output.publicAddress,
339-
amount: txn.bridge_output.amount,
340-
memoHex: txn.bridge_output.memoHex,
341-
assetId: txn.bridge_output.assetId,
342-
},
343-
{
344-
publicAddress: txn.gas_fee_output.publicAddress,
345-
amount: txn.gas_fee_output.amount,
346-
memo: txn.gas_fee_output.memo,
347-
},
348-
],
367+
outputs,
349368
fee: flags.fee ? CurrencyUtils.encode(flags.fee) : null,
350369
feeRate: flags.feeRate ? CurrencyUtils.encode(flags.feeRate) : null,
351370
expiration,
@@ -406,31 +425,15 @@ export class BridgeCommand extends IronfishCommand {
406425

407426
const targetNetworkFee = CurrencyUtils.render(BigInt(txn.gas_fee_output.amount), true)
408427

409-
let chainportFee: string
410-
411-
if (txn.bridge_fee.is_portx_fee_payment) {
412-
this.logger.log('\nStaked PortX detected')
413-
414-
chainportFee = CurrencyUtils.render(
415-
BigInt(txn.bridge_fee.portx_fee_amount),
416-
true,
417-
'portx asset id',
418-
{
419-
decimals: 18,
420-
symbol: 'PORTX',
421-
},
422-
)
423-
} else {
424-
chainportFee = CurrencyUtils.render(
425-
BigInt(txn.bridge_fee.source_token_fee_amount ?? 0),
426-
true,
427-
'chainport fee id',
428-
{
429-
decimals: sourceToken.decimals,
430-
symbol: sourceAsset.verification.symbol,
431-
},
432-
)
433-
}
428+
const chainportFee = CurrencyUtils.render(
429+
BigInt(txn.bridge_fee.source_token_fee_amount ?? 0),
430+
true,
431+
'chainport fee id',
432+
{
433+
decimals: sourceToken.decimals,
434+
symbol: sourceAsset.verification.symbol,
435+
},
436+
)
434437

435438
const summary = `\
436439
\nBRIDGE TRANSACTION SUMMARY:

ironfish-cli/src/utils/chainport/metadata.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@ describe('ChainportMemoMetadata', () => {
4848
'02161ca1882c130f04f04638f2092851863c518018c0012ca1093c438b10200a',
4949
),
5050
).toEqual([22, '0x7A68B1Cf1F16Ef89A566F5606C01BA49F4Eb420A'.toLowerCase(), true])
51+
52+
expect(
53+
ChainportMemoMetadata.decode(
54+
'004f99a1a130db7faf2d00d729ad1fc41c76547c5646d10f28e0000000000000',
55+
),
56+
).toEqual([15, '0x99A1a130DB7FAf2d00d729aD1FC41c76547c5646'.toLowerCase(), false])
5157
})
5258

53-
test('encode and decode are reversible', () => {
59+
test('encode and decode are reversible v1', () => {
5460
const networkId = 2
5561
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a'
5662
const toIronfish = false
@@ -60,4 +66,91 @@ describe('ChainportMemoMetadata', () => {
6066

6167
expect(decoded).toEqual([networkId, '0x' + address.toLowerCase(), toIronfish])
6268
})
69+
70+
test('encode and decode are reversible v2', () => {
71+
const networkId = 2
72+
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a'
73+
const toIronfish = false
74+
const timestamp = 1753715824
75+
const version = 1
76+
77+
const encoded = ChainportMemoMetadata.encodeV2(
78+
networkId,
79+
address,
80+
toIronfish,
81+
timestamp,
82+
version,
83+
)
84+
const decoded = ChainportMemoMetadata.decode(encoded)
85+
86+
expect(decoded).toEqual([networkId, '0x' + address.toLowerCase(), toIronfish])
87+
})
88+
89+
test('should throw error if networkId is greater than 63', () => {
90+
const networkId = 64
91+
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a'
92+
const toIronfish = false
93+
const timestamp = 1753715824
94+
const version = 1
95+
96+
expect(() =>
97+
ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version),
98+
).toThrow('networkId exceeds 6-bit capacity')
99+
})
100+
101+
test('should throw error if version is greater than 3', () => {
102+
const networkId = 2
103+
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a'
104+
const toIronfish = false
105+
const timestamp = 1753715824
106+
const version = 4
107+
108+
expect(() =>
109+
ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version),
110+
).toThrow('version exceeds 2-bit capacity')
111+
})
112+
113+
test('should throw error if timestamp is greater than 2147483647', () => {
114+
const networkId = 2
115+
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a'
116+
const toIronfish = false
117+
const timestamp = 2147483648
118+
const version = 1
119+
120+
expect(() =>
121+
ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version),
122+
).toThrow('timestamp exceeds 31-bit capacity')
123+
})
124+
125+
test('should throw error if address is not 40 hexadecimal characters', () => {
126+
const networkId = 2
127+
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9atest'
128+
const toIronfish = false
129+
const timestamp = 1753715824
130+
const version = 1
131+
132+
expect(() =>
133+
ChainportMemoMetadata.encodeV2(networkId, address, toIronfish, timestamp, version),
134+
).toThrow('address must be 40 hexadecimal characters')
135+
})
136+
137+
test('should throw error if memoHex version is not 1 for decodeV2', () => {
138+
const networkId = 2
139+
const address = '5DF170F118753CaE92aaC2868A2C25Ccb6528f9a'
140+
const toIronfish = false
141+
const timestamp = 1753715824
142+
const version = 2
143+
144+
const encoded = ChainportMemoMetadata.encodeV2(
145+
networkId,
146+
address,
147+
toIronfish,
148+
timestamp,
149+
version,
150+
)
151+
152+
expect(() => ChainportMemoMetadata.decodeV2(encoded)).toThrow(
153+
'Unexpected memoHex version: 10',
154+
)
155+
})
63156
})

ironfish-cli/src/utils/chainport/metadata.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ export class ChainportMemoMetadata {
4444
return String.fromCharCode(num - 10 + 'a'.charCodeAt(0))
4545
}
4646

47+
public static convertHexToBinary(encodedHex: string): string {
48+
const buffer = Buffer.from(encodedHex, 'hex')
49+
let binaryString = ''
50+
for (let i = 0; i < buffer.length; i++) {
51+
binaryString += buffer[i].toString(2).padStart(8, '0')
52+
}
53+
return binaryString
54+
}
55+
4756
public static encode(networkId: number, address: string, toIronfish: boolean) {
4857
if (address.startsWith('0x')) {
4958
address = address.slice(2)
@@ -63,7 +72,86 @@ export class ChainportMemoMetadata {
6372
return hexString.padStart(64, '0')
6473
}
6574

66-
public static decode(encodedHex: string): [number, string, boolean] {
75+
public static encodeV2(
76+
networkId: number,
77+
address: string,
78+
toIronfish: boolean,
79+
timestamp: number,
80+
version: number,
81+
) {
82+
if (networkId >= 1 << 6) {
83+
throw new Error('networkId exceeds 6-bit capacity')
84+
}
85+
if (version >= 1 << 2) {
86+
throw new Error('version exceeds 2-bit capacity')
87+
}
88+
if (BigInt(timestamp) >= 1n << 31n) {
89+
throw new Error('timestamp exceeds 31-bit capacity')
90+
}
91+
92+
let addressClean = address
93+
if (addressClean.startsWith('0x')) {
94+
addressClean = addressClean.slice(2)
95+
}
96+
97+
if (addressClean.length !== 40) {
98+
throw new Error('address must be 40 hexadecimal characters')
99+
}
100+
101+
const addrBytes = Buffer.from(addressClean, 'hex')
102+
103+
if (addrBytes.length !== 20) {
104+
throw new Error('address must decode to 20 bytes')
105+
}
106+
107+
const bitArray: number[] = new Array(256).fill(0) as number[]
108+
let pos = 0
109+
110+
pos += 6
111+
112+
bitArray[pos] = toIronfish ? 1 : 0
113+
pos += 1
114+
115+
pos += 1
116+
117+
bitArray[pos] = (version >> 1) & 1
118+
bitArray[pos + 1] = version & 1
119+
pos += 2
120+
121+
for (let i = 0; i < 6; i++) {
122+
bitArray[pos + i] = (networkId >> (5 - i)) & 1
123+
}
124+
pos += 6
125+
126+
for (const byte of addrBytes) {
127+
for (let i = 0; i < 8; i++) {
128+
bitArray[pos] = (byte >> (7 - i)) & 1
129+
pos += 1
130+
}
131+
}
132+
133+
for (let i = 0; i < 31; i++) {
134+
bitArray[pos + i] = (timestamp >> (30 - i)) & 1
135+
}
136+
pos += 31
137+
138+
pos += 49
139+
140+
const result = new Uint8Array(32)
141+
for (let i = 0; i < 32; i++) {
142+
let byte = 0
143+
for (let j = 0; j < 8; j++) {
144+
byte = (byte << 1) | bitArray[i * 8 + j]
145+
}
146+
result[i] = byte
147+
}
148+
149+
return Array.from(result)
150+
.map((byte) => byte.toString(16).padStart(2, '0'))
151+
.join('')
152+
}
153+
154+
public static decodeV1(encodedHex: string): [number, string, boolean] {
67155
const hexInteger = BigInt('0x' + encodedHex)
68156
const encodedString = hexInteger.toString(2)
69157
const padded = encodedString.padStart(250, '0')
@@ -82,4 +170,32 @@ export class ChainportMemoMetadata {
82170

83171
return [networkId, address.toLowerCase(), toIronfish]
84172
}
173+
174+
public static decodeV2(encodedHex: string): [number, string, boolean] {
175+
const bits = this.convertHexToBinary(encodedHex)
176+
const toIronfish = bits[6] === '1'
177+
const memoHexVersion = bits.slice(8, 10)
178+
if (memoHexVersion !== '01') {
179+
throw new Error(`Unexpected memoHex version: ${memoHexVersion}`)
180+
}
181+
182+
const networkIdBits = bits.slice(10, 16)
183+
const networkId = parseInt(networkIdBits, 2)
184+
const addressBits = bits.slice(16, 176)
185+
let address = '0x'
186+
for (let i = 0; i < addressBits.length; i += 4) {
187+
address += parseInt(addressBits.slice(i, i + 4), 2).toString(16)
188+
}
189+
190+
return [networkId, address.toLowerCase(), toIronfish]
191+
}
192+
193+
public static decode(encodedHex: string): [number, string, boolean] {
194+
const bits = this.convertHexToBinary(encodedHex)
195+
const memoHexVersion = bits.slice(8, 10)
196+
if (memoHexVersion === '01') {
197+
return this.decodeV2(encodedHex)
198+
}
199+
return this.decodeV1(encodedHex)
200+
}
85201
}

ironfish-cli/src/utils/chainport/requests.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,15 @@ export const fetchChainportBridgeTransaction = async (
5757
assetId: string,
5858
targetNetworkId: number,
5959
targetAddress: string,
60+
sourceAddress: string,
6061
): Promise<ChainportBridgeTransaction> => {
6162
const config = getConfig(networkId)
6263
const url = new URL(`/bridges/transactions/create`, config.endpoint)
6364
url.searchParams.append('amount', amount.toString())
6465
url.searchParams.append('asset_id', assetId)
6566
url.searchParams.append('target_network_id', targetNetworkId.toString())
6667
url.searchParams.append('target_address', targetAddress.toString())
68+
url.searchParams.append('source_address', sourceAddress)
6769

6870
return await makeChainportRequest<ChainportBridgeTransaction>(url.toString())
6971
}

ironfish-cli/src/utils/chainport/types.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@
44

55
// This file contains response types for chainport requests
66

7+
export type ChainportBridgeFeeV1 = {
8+
source_token_fee_amount: string
9+
portx_fee_amount: string
10+
is_portx_fee_payment: boolean
11+
}
12+
13+
export type ChainportBridgeFeeV2 = {
14+
publicAddress: string
15+
source_token_fee_amount: string
16+
memo: string
17+
assetId: string
18+
}
19+
720
export type ChainportBridgeTransaction = {
821
bridge_output: {
922
publicAddress: string
@@ -16,11 +29,7 @@ export type ChainportBridgeTransaction = {
1629
amount: string
1730
memo: string
1831
}
19-
bridge_fee: {
20-
source_token_fee_amount: string
21-
portx_fee_amount: string
22-
is_portx_fee_payment: boolean
23-
}
32+
bridge_fee: ChainportBridgeFeeV2
2433
}
2534

2635
export type ChainportNetwork = {

0 commit comments

Comments
 (0)