From a87e4ca615993bdd1b66622d8e62ae5cc86ea4ab Mon Sep 17 00:00:00 2001 From: N V Rakesh Reddy Date: Thu, 18 Jun 2026 15:41:40 +0530 Subject: [PATCH] feat(sdk-coin-polyx): add HexTokenTransferBuilder for NEW token memo encoding (CECHO-1381) Adds HexTokenTransferBuilder extending TokenTransferBuilder to produce the NEW Polymesh memo encoding (UTF-8 hex, right-padded with 0x00) for the settlement.addAndAffirmWithMediators instructionMemo field. Registers getHexTokenTransferBuilder() on TransactionBuilderFactory and wires auto- detection into factory.from() so round-trips route to the correct builder. Ticket: CECHO-1381 Co-authored-by: Cursor --- .../src/lib/hexTokenTransferBuilder.ts | 37 +++ modules/sdk-coin-polyx/src/lib/index.ts | 1 + .../src/lib/transactionBuilderFactory.ts | 11 +- .../hexTokenTransferBuilder.ts | 296 ++++++++++++++++++ 4 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-polyx/src/lib/hexTokenTransferBuilder.ts create mode 100644 modules/sdk-coin-polyx/test/unit/transactionBuilder/hexTokenTransferBuilder.ts diff --git a/modules/sdk-coin-polyx/src/lib/hexTokenTransferBuilder.ts b/modules/sdk-coin-polyx/src/lib/hexTokenTransferBuilder.ts new file mode 100644 index 0000000000..13fde5f3e2 --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/hexTokenTransferBuilder.ts @@ -0,0 +1,37 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { InvalidTransactionError } from '@bitgo/sdk-core'; +import { TokenTransferBuilder } from './tokenTransferBuilder'; +import { MEMO_HEX_REGEX } from './constants'; +import utils from './utils'; + +/** + * Builds a POLYX settlement.addAndAffirmWithMediators transaction using the NEW Polymesh memo encoding: + * UTF-8 bytes right-padded with 0x00 to 32 bytes, passed as a 0x-prefixed hex string. + * + * OLD encoding (TokenTransferBuilder): plain string, left-padded with ASCII '0' to 32 chars. + * NEW encoding (this class): 0x-prefixed hex, right-padded with 0x00 to 32 bytes. + */ +export class HexTokenTransferBuilder extends TokenTransferBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** + * Encode memo using the NEW Polymesh format. + * + * If the value is already a 0x-prefixed 32-byte hex string (e.g. when decoding an + * existing NEW-encoded transaction via fromImplementation), it is stored as-is. + * Otherwise the text is converted to UTF-8 hex and right-padded with 0x00 to 32 bytes. + */ + memo(memo: string): this { + if (memo.startsWith('0x') && memo.length === 66) { + if (!MEMO_HEX_REGEX.test(memo)) { + throw new InvalidTransactionError(`Invalid memo hex encoding: ${memo}`); + } + this._memo = memo; + } else { + this._memo = utils.encodeMemoNew(memo); + } + return this; + } +} diff --git a/modules/sdk-coin-polyx/src/lib/index.ts b/modules/sdk-coin-polyx/src/lib/index.ts index 91d09bf6bc..69e4e45c06 100644 --- a/modules/sdk-coin-polyx/src/lib/index.ts +++ b/modules/sdk-coin-polyx/src/lib/index.ts @@ -15,6 +15,7 @@ export { HexTransferBuilder } from './hexTransferBuilder'; export { RegisterDidWithCDDBuilder } from './registerDidWithCDDBuilder'; export { PreApproveAssetBuilder } from './preApproveAssetBuilder'; export { TokenTransferBuilder } from './tokenTransferBuilder'; +export { HexTokenTransferBuilder } from './hexTokenTransferBuilder'; export { RejectInstructionBuilder } from './rejectInstructionBuilder'; export { Transaction as PolyxTransaction } from './transaction'; export { BondExtraBuilder } from './bondExtraBuilder'; diff --git a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts index 4c63d76055..42e5957461 100644 --- a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts @@ -11,11 +11,12 @@ import { UnbondBuilder } from './unbondBuilder'; import { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder'; import utils from './utils'; import { Interface, SingletonRegistry, TransactionBuilder } from './'; -import { TxMethod, BatchCallObject, MethodNames } from './iface'; +import { TxMethod, BatchCallObject, MethodNames, AddAndAffirmWithMediatorsArgs } from './iface'; import { Transaction as BaseTransaction } from '@bitgo/abstract-substrate'; import { Transaction as PolyxTransaction } from './transaction'; import { PreApproveAssetBuilder } from './preApproveAssetBuilder'; import { TokenTransferBuilder } from './tokenTransferBuilder'; +import { HexTokenTransferBuilder } from './hexTokenTransferBuilder'; import { RejectInstructionBuilder } from './rejectInstructionBuilder'; import { NominateBuilder } from './nominateBuilder'; @@ -49,6 +50,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new TokenTransferBuilder(this._coinConfig).material(this._material); } + getHexTokenTransferBuilder(): HexTokenTransferBuilder { + return new HexTokenTransferBuilder(this._coinConfig).material(this._material); + } + getRejectInstructionBuilder(): RejectInstructionBuilder { return new RejectInstructionBuilder(this._coinConfig).material(this._material); } @@ -111,6 +116,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { } else if (methodName === MethodNames.PreApproveAsset) { return this.getPreApproveAssetBuilder(); } else if (methodName === MethodNames.AddAndAffirmWithMediators) { + const args = decodedTxn.method.args as AddAndAffirmWithMediatorsArgs; + if (utils.isNewMemoEncoding(args.instructionMemo)) { + return this.getHexTokenTransferBuilder(); + } return this.getTokenTransferBuilder(); } else if (methodName === MethodNames.RejectInstruction) { return this.getRejectInstructionBuilder(); diff --git a/modules/sdk-coin-polyx/test/unit/transactionBuilder/hexTokenTransferBuilder.ts b/modules/sdk-coin-polyx/test/unit/transactionBuilder/hexTokenTransferBuilder.ts new file mode 100644 index 0000000000..c42f1661c3 --- /dev/null +++ b/modules/sdk-coin-polyx/test/unit/transactionBuilder/hexTokenTransferBuilder.ts @@ -0,0 +1,296 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { HexTokenTransferBuilder, TokenTransferBuilder, TransactionBuilderFactory } from '../../../src/lib'; +import { utils } from '../../../src'; +import { accounts, mockTssSignature } from '../../resources'; +import { buildTestConfig } from './base'; + +const NEW_MEMO_HEX: Record = { + 0: '0x3000000000000000000000000000000000000000000000000000000000000000', + 56594: '0x3536353934000000000000000000000000000000000000000000000000000000', + testmemo: '0x746573746d656d6f000000000000000000000000000000000000000000000000', + 102329716: '0x3130323332393731360000000000000000000000000000000000000000000000', +}; + +const senderDID = '0x28e8649fec23dd688090b9b5bb950fd34bf20a014cf05542e3ad0264915ee775'; +const receiverDID = '0x9202856204a721d2f5e8b85408067d54f1ca84390bf4f558b5615a5a6d3bddb8'; +const assetId = '0x780602887b358cf48989d0d9aa6c8d28'; + +const commonBuilderParams = (builder: HexTokenTransferBuilder, sender: (typeof accounts)['rbitgoTokenOwner']) => { + return builder + .toDID(receiverDID) + .fromDID(senderDID) + .assetId(assetId) + .amount('100') + .sender({ address: sender.address }) + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d') + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 200 }) + .fee({ amount: 0, type: 'tip' }); +}; + +describe('HexTokenTransferBuilder — memo encoding', () => { + let builder: HexTokenTransferBuilder; + + beforeEach(() => { + const config = buildTestConfig(); + builder = new HexTokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + }); + + describe('memo() produces NEW encoding', () => { + it('encodes "0"', () => { + builder.memo('0'); + should.equal((builder as any)._memo, NEW_MEMO_HEX['0']); + }); + + it('encodes "56594"', () => { + builder.memo('56594'); + should.equal((builder as any)._memo, NEW_MEMO_HEX['56594']); + }); + + it('encodes "testmemo"', () => { + builder.memo('testmemo'); + should.equal((builder as any)._memo, NEW_MEMO_HEX['testmemo']); + }); + + it('encodes "102329716"', () => { + builder.memo('102329716'); + should.equal((builder as any)._memo, NEW_MEMO_HEX['102329716']); + }); + + it('stores an already-encoded 0x hex memo as-is (round-trip)', () => { + const alreadyEncoded = NEW_MEMO_HEX['56594']; + builder.memo(alreadyEncoded); + should.equal((builder as any)._memo, alreadyEncoded); + }); + }); + + describe('NEW encoding differs from OLD encoding', () => { + it('produces different bytes than TokenTransferBuilder for "56594"', () => { + const config = buildTestConfig(); + const oldBuilder = new TokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + const newBuilder = new HexTokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + + oldBuilder.memo('56594'); + newBuilder.memo('56594'); + + const oldMemo = (oldBuilder as any)._memo; + const newMemo = (newBuilder as any)._memo; + + should.notEqual(oldMemo, newMemo); + should.ok(!oldMemo.startsWith('0x'), `OLD memo should not start with 0x, got: ${oldMemo}`); + should.ok(newMemo.startsWith('0x'), `NEW memo should start with 0x, got: ${newMemo}`); + should.equal(newMemo, NEW_MEMO_HEX['56594']); + }); + + it('produces different bytes than TokenTransferBuilder for "0"', () => { + const config = buildTestConfig(); + const oldBuilder = new TokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + const newBuilder = new HexTokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + + oldBuilder.memo('0'); + newBuilder.memo('0'); + + should.notEqual((oldBuilder as any)._memo, (newBuilder as any)._memo); + }); + }); +}); + +describe('HexTokenTransferBuilder — build transaction', () => { + const sender = accounts.rbitgoTokenOwner; + let builder: HexTokenTransferBuilder; + + beforeEach(() => { + const config = buildTestConfig(); + builder = new HexTokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + }); + + it('should build a valid addAndAffirmWithMediators tx with NEW memo "56594"', async () => { + commonBuilderParams(builder, sender).memo('56594'); + builder.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + const tx = await builder.build(); + const txJson = tx.toJson(); + should.deepEqual(txJson.amount, '100'); + should.deepEqual(txJson.toDID, receiverDID); + should.deepEqual(txJson.fromDID, senderDID); + should.deepEqual(txJson.assetId, assetId); + should.deepEqual(txJson.memo, NEW_MEMO_HEX['56594']); + }); + + it('should build a valid tx with NEW memo "testmemo"', async () => { + commonBuilderParams(builder, sender).memo('testmemo'); + builder.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + const tx = await builder.build(); + should.deepEqual(tx.toJson().memo, NEW_MEMO_HEX['testmemo']); + }); + + it('should build a valid tx with NEW memo "0" (default)', async () => { + commonBuilderParams(builder, sender).memo('0'); + builder.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + const tx = await builder.build(); + should.deepEqual(tx.toJson().memo, NEW_MEMO_HEX['0']); + }); + + it('should build a valid tx with NEW memo "102329716"', async () => { + commonBuilderParams(builder, sender).memo('102329716'); + builder.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + const tx = await builder.build(); + should.deepEqual(tx.toJson().memo, NEW_MEMO_HEX['102329716']); + }); + + it('raw transaction bytes differ between OLD and NEW builder for same memo', async () => { + const config = buildTestConfig(); + const material = utils.getMaterial(config.network.type); + + const buildOld = async () => { + const b = new TokenTransferBuilder(config) + .material(material) + .toDID(receiverDID) + .fromDID(senderDID) + .assetId(assetId) + .amount('100') + .memo('56594') + .sender({ address: sender.address }) + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d') + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 200 }) + .fee({ amount: 0, type: 'tip' }); + b.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + return (await b.build()).toBroadcastFormat(); + }; + + const buildNew = async () => { + const b = new HexTokenTransferBuilder(config) + .material(material) + .toDID(receiverDID) + .fromDID(senderDID) + .assetId(assetId) + .amount('100') + .memo('56594') + .sender({ address: sender.address }) + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d') + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 200 }) + .fee({ amount: 0, type: 'tip' }); + b.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + return (await b.build()).toBroadcastFormat(); + }; + + should.notEqual(await buildOld(), await buildNew()); + }); +}); + +describe('HexTokenTransferBuilder — factory.from() round-trip', () => { + const sender = accounts.rbitgoTokenOwner; + const commonParams = { + firstValid: 3933, + maxDuration: 64, + referenceBlock: '0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d', + nonce: 200, + }; + let factory: TransactionBuilderFactory; + + beforeEach(() => { + factory = new TransactionBuilderFactory(coins.get('tpolyx')); + }); + + const buildSigned = async (memo: string) => { + const config = buildTestConfig(); + const b = new HexTokenTransferBuilder(config) + .material(utils.getMaterial(config.network.type)) + .toDID(receiverDID) + .fromDID(senderDID) + .assetId(assetId) + .amount('100') + .memo(memo) + .sender({ address: sender.address }) + .validity({ firstValid: commonParams.firstValid, maxDuration: commonParams.maxDuration }) + .referenceBlock(commonParams.referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: commonParams.nonce }) + .fee({ amount: 0, type: 'tip' }); + b.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + return (await b.build()).toBroadcastFormat(); + }; + + it('factory.from() returns HexTokenTransferBuilder for a NEW-encoded token transfer', async () => { + const serialized = await buildSigned('56594'); + const rebuilt = factory.from(serialized); + rebuilt.should.be.instanceOf(HexTokenTransferBuilder); + }); + + it('factory.from() returns TokenTransferBuilder (not Hex) for an OLD-encoded token transfer', async () => { + const config = buildTestConfig(); + const b = new TokenTransferBuilder(config) + .material(utils.getMaterial(config.network.type)) + .toDID(receiverDID) + .fromDID(senderDID) + .assetId(assetId) + .amount('100') + .memo('56594') + .sender({ address: sender.address }) + .validity({ firstValid: commonParams.firstValid, maxDuration: commonParams.maxDuration }) + .referenceBlock(commonParams.referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: commonParams.nonce }) + .fee({ amount: 0, type: 'tip' }); + b.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + const serialized = (await b.build()).toBroadcastFormat(); + + const rebuilt = factory.from(serialized); + rebuilt.should.be.instanceOf(TokenTransferBuilder); + rebuilt.should.not.be.instanceOf(HexTokenTransferBuilder); + }); + + it('round-trip preserves memo bytes for multiple memos', async () => { + for (const memo of ['56594', 'testmemo', '0', '102329716']) { + const original = await buildSigned(memo); + const rebuilt = factory + .from(original) + .validity({ firstValid: commonParams.firstValid, maxDuration: commonParams.maxDuration }) + .referenceBlock(commonParams.referenceBlock); + const roundTripped = (await rebuilt.build()).toBroadcastFormat(); + should.equal(roundTripped, original, `round-trip failed for memo "${memo}"`); + } + }); + + it('rebuilt tx has correct _memo value after round-trip', async () => { + const serialized = await buildSigned('56594'); + const rebuilt = factory.from(serialized); + should.equal((rebuilt as any)._memo, NEW_MEMO_HEX['56594']); + }); +}); + +describe('HexTokenTransferBuilder — memo validation', () => { + let builder: HexTokenTransferBuilder; + + beforeEach(() => { + const config = buildTestConfig(); + builder = new HexTokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + }); + + it('throws when memo exceeds 32 UTF-8 bytes', () => { + should.throws(() => builder.memo('a'.repeat(33)), /exceeds maximum length of 32 bytes/); + }); + + it('throws for invalid hex memo', () => { + should.throws(() => builder.memo('0x' + 'zz'.repeat(32)), /Invalid memo hex encoding/); + }); +}); + +describe('TokenTransferBuilder — no regression from HexTokenTransferBuilder', () => { + it('OLD TokenTransferBuilder still produces OLD encoding for "56594"', () => { + const config = buildTestConfig(); + const builder = new TokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + builder.memo('56594'); + const memo = (builder as any)._memo; + should.ok(!memo.startsWith('0x'), `OLD memo should not start with 0x, got: ${memo}`); + should.equal(memo, '00000000000000000000000000056594'.slice(-32)); + }); + + it('OLD TokenTransferBuilder still produces OLD encoding for "0"', () => { + const config = buildTestConfig(); + const builder = new TokenTransferBuilder(config).material(utils.getMaterial(config.network.type)); + builder.memo('0'); + const memo = (builder as any)._memo; + should.equal(memo, '0'.padStart(32, '0')); + }); +});