From 8472e3c092bfcd53d0e0986ed431d571b6367dbf Mon Sep 17 00:00:00 2001 From: Paras Garg Date: Thu, 27 Nov 2025 19:37:05 +0530 Subject: [PATCH] feat(sdk-coin-iota): add signature serialization for iota transaction Ticket: WIN-8065 --- modules/sdk-coin-iota/src/lib/transaction.ts | 61 ++++++- modules/sdk-coin-iota/test/resources/iota.ts | 27 +++ .../transactionBuilder/transferBuilder.ts | 153 +++++++++++++++++ .../test/unit/transferTransaction.ts | 156 ++++++++++++++++++ 4 files changed, 389 insertions(+), 8 deletions(-) diff --git a/modules/sdk-coin-iota/src/lib/transaction.ts b/modules/sdk-coin-iota/src/lib/transaction.ts index 5f00748fa8..c45860fe4c 100644 --- a/modules/sdk-coin-iota/src/lib/transaction.ts +++ b/modules/sdk-coin-iota/src/lib/transaction.ts @@ -23,6 +23,7 @@ import { IOTA_KEY_BYTES_LENGTH, IOTA_SIGNATURE_LENGTH, } from './constants'; +import utils from './utils'; export abstract class Transaction extends BaseTransaction { static EMPTY_PUBLIC_KEY = Buffer.alloc(IOTA_KEY_BYTES_LENGTH); @@ -38,7 +39,9 @@ export abstract class Transaction extends BaseTransaction { private _gasSponsor?: string; private _sender: string; private _signature?: Signature; + private _serializedSignature?: string; private _gasSponsorSignature?: Signature; + private _serializedGasSponsorSignature?: string; private _txDataBytes?: Uint8Array; private _isSimulateTx: boolean; @@ -47,12 +50,6 @@ export abstract class Transaction extends BaseTransaction { this._sender = ''; this._rebuildRequired = false; this._isSimulateTx = true; - this._signature = { - publicKey: { - pub: Transaction.EMPTY_PUBLIC_KEY.toString('hex'), - }, - signature: Transaction.EMPTY_SIGNATURE, - }; } get gasBudget(): number | undefined { @@ -137,12 +134,10 @@ export abstract class Transaction extends BaseTransaction { } addSignature(publicKey: PublicKey, signature: Buffer): void { - this._signatures = [...this._signatures, signature.toString('hex')]; this._signature = { publicKey, signature }; } addGasSponsorSignature(publicKey: PublicKey, signature: Buffer): void { - this._signatures = [...this._signatures, signature.toString('hex')]; this._gasSponsorSignature = { publicKey, signature }; } @@ -154,6 +149,26 @@ export abstract class Transaction extends BaseTransaction { return this.gasBudget?.toString(); } + get serializedGasSponsorSignature(): string | undefined { + return this._serializedGasSponsorSignature; + } + + get serializedSignature(): string | undefined { + return this._serializedSignature; + } + + serializeSignatures(): void { + this._signatures = []; + if (this._signature) { + this._serializedSignature = this.serializeSignature(this._signature as Signature); + this._signatures.push(this._serializedSignature); + } + if (this._gasSponsorSignature) { + this._serializedGasSponsorSignature = this.serializeSignature(this._gasSponsorSignature as Signature); + this._signatures.push(this._serializedGasSponsorSignature); + } + } + async toBroadcastFormat(): Promise { const txDataBytes: Uint8Array = await this.build(); return toBase64(txDataBytes); @@ -291,6 +306,7 @@ export abstract class Transaction extends BaseTransaction { this._txDataBytes = await this._iotaTransaction.build(); this._rebuildRequired = false; } + this.serializeSignatures(); return this._txDataBytes; } @@ -306,6 +322,15 @@ export abstract class Transaction extends BaseTransaction { this._iotaTransaction.setSender(this.sender); } + private serializeSignature(signature: Signature): string { + const pubKey = Buffer.from(signature.publicKey.pub, 'hex'); + const serialized_sig = new Uint8Array(1 + signature.signature.length + pubKey.length); + serialized_sig.set([0x00]); //Hardcoding the signature scheme flag since we only support EDDSA for iota + serialized_sig.set(signature.signature, 1); + serialized_sig.set(pubKey, 1 + signature.signature.length); + return toBase64(serialized_sig); + } + private validateTxData(): void { this.validateTxDataImplementation(); if (!this.sender || this.sender === '') { @@ -329,5 +354,25 @@ export abstract class Transaction extends BaseTransaction { `Gas payment objects count (${this.gasPaymentObjects.length}) exceeds maximum allowed (${MAX_GAS_PAYMENT_OBJECTS})` ); } + + if ( + this._signature && + !( + utils.isValidPublicKey(this._signature.publicKey.pub) && + utils.isValidSignature(toBase64(this._signature.signature)) + ) + ) { + throw new InvalidTransactionError('Invalid sender signature'); + } + + if ( + this._gasSponsorSignature && + !( + utils.isValidPublicKey(this._gasSponsorSignature.publicKey.pub) && + utils.isValidSignature(toBase64(this._gasSponsorSignature.signature)) + ) + ) { + throw new InvalidTransactionError('Invalid gas sponsor signature'); + } } } diff --git a/modules/sdk-coin-iota/test/resources/iota.ts b/modules/sdk-coin-iota/test/resources/iota.ts index 7f37e92d4c..5769705d84 100644 --- a/modules/sdk-coin-iota/test/resources/iota.ts +++ b/modules/sdk-coin-iota/test/resources/iota.ts @@ -78,3 +78,30 @@ export const generateObjects = (count: number): TransactionObjectInput[] => { digest: `digest${i}`, })); }; + +// Test signature data for signature serialization tests +export const testSignature = { + // 64-byte signature (hex string) + signature: Buffer.from( + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2' + + 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4', + 'hex' + ), + // Public key (already defined in sender) + publicKey: { + pub: sender.publicKey, + }, +}; + +export const testGasSponsorSignature = { + // 64-byte signature (hex string) + signature: Buffer.from( + 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5' + + 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7', + 'hex' + ), + // Public key (already defined in gasSponsor) + publicKey: { + pub: gasSponsor.publicKey, + }, +}; diff --git a/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts index ebdb83f496..7aff491a0d 100644 --- a/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-iota/test/unit/transactionBuilder/transferBuilder.ts @@ -306,4 +306,157 @@ describe('Iota Transfer Builder', () => { should(() => factory.from('invalidRawTransaction')).throwError(); }); }); + + describe('Transaction Signing', () => { + it('should build transaction with sender signature', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add signature + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + should.exist(tx.serializedSignature); + should.equal(tx.serializedSignature!.length > 0, true); + }); + + it('should build transaction with gas sponsor signature', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add gas sponsor signature + tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + should.exist(tx.serializedGasSponsorSignature); + should.equal(tx.serializedGasSponsorSignature!.length > 0, true); + }); + + it('should build transaction with both sender and gas sponsor signatures', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add both signatures + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + should.exist(tx.serializedSignature); + should.exist(tx.serializedGasSponsorSignature); + tx.signature.length.should.equal(2); + }); + + it('should add signature through builder and serialize correctly', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + // Add signature through builder + txBuilder.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + + const tx = (await txBuilder.build()) as TransferTransaction; + + should.exist(tx.serializedSignature); + should.equal(typeof tx.serializedSignature, 'string'); + // Verify signature array is populated + tx.signature.length.should.equal(1); + }); + + it('should add gas sponsor signature through builder and serialize correctly', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + // Add gas sponsor signature through builder + txBuilder.addGasSponsorSignature( + testData.testGasSponsorSignature.publicKey, + testData.testGasSponsorSignature.signature + ); + + const tx = (await txBuilder.build()) as TransferTransaction; + + should.exist(tx.serializedGasSponsorSignature); + should.equal(typeof tx.serializedGasSponsorSignature, 'string'); + // Verify signature array is populated + tx.signature.length.should.equal(1); + }); + + it('should serialize signatures in correct order', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + // Add signatures through builder + txBuilder.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + txBuilder.addGasSponsorSignature( + testData.testGasSponsorSignature.publicKey, + testData.testGasSponsorSignature.signature + ); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Verify signatures are in correct order: sender first, gas sponsor second + tx.signature.length.should.equal(2); + tx.signature[0].should.equal(tx.serializedSignature); + tx.signature[1].should.equal(tx.serializedGasSponsorSignature); + }); + + it('should fail to add invalid sender signature via builder', function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + // Builder should validate and throw when adding invalid signature + should(() => txBuilder.addSignature({ pub: 'tooshort' }, testData.testSignature.signature)).throwError( + 'Invalid transaction signature' + ); + }); + + it('should fail to add invalid gas sponsor signature via builder', function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + // Builder should validate and throw when adding invalid signature + should(() => + txBuilder.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, Buffer.from('invalid')) + ).throwError('Invalid transaction signature'); + }); + }); }); diff --git a/modules/sdk-coin-iota/test/unit/transferTransaction.ts b/modules/sdk-coin-iota/test/unit/transferTransaction.ts index 873de4918a..14f09827f6 100644 --- a/modules/sdk-coin-iota/test/unit/transferTransaction.ts +++ b/modules/sdk-coin-iota/test/unit/transferTransaction.ts @@ -374,4 +374,160 @@ describe('Iota Transfer Transaction', () => { should(() => tx.id).throwError('Tx not built or a rebuild is required'); }); }); + + describe('Signature Serialization', () => { + it('should have undefined serializedSignature before signing', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + const tx = (await txBuilder.build()) as TransferTransaction; + + should.equal(tx.serializedSignature, undefined); + should.equal(tx.serializedGasSponsorSignature, undefined); + }); + + it('should serialize signature after adding and rebuilding', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add signature + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + should.exist(tx.serializedSignature); + should.equal(typeof tx.serializedSignature, 'string'); + // Verify it's valid base64 + should.equal(/^[A-Za-z0-9+/]*={0,2}$/.test(tx.serializedSignature as string), true); + }); + + it('should serialize gas sponsor signature correctly', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add gas sponsor signature + tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + should.exist(tx.serializedGasSponsorSignature); + should.equal(typeof tx.serializedGasSponsorSignature, 'string'); + // Verify it's valid base64 + should.equal(/^[A-Za-z0-9+/]*={0,2}$/.test(tx.serializedGasSponsorSignature as string), true); + }); + + it('should serialize both sender and gas sponsor signatures', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add both signatures + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + should.exist(tx.serializedSignature); + should.exist(tx.serializedGasSponsorSignature); + should.notEqual(tx.serializedSignature, tx.serializedGasSponsorSignature); + }); + + it('should include serialized signatures in signatures array', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add signature + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + // Check that signatures array contains the serialized signature + tx.signature.length.should.equal(1); + tx.signature[0].should.equal(tx.serializedSignature); + }); + + it('should include both signatures in signatures array when gas sponsor is present', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + txBuilder.gasSponsor(testData.gasSponsor.address); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add both signatures + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + tx.addGasSponsorSignature(testData.testGasSponsorSignature.publicKey, testData.testGasSponsorSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + // Check that signatures array contains both serialized signatures + tx.signature.length.should.equal(2); + tx.signature[0].should.equal(tx.serializedSignature); + tx.signature[1].should.equal(tx.serializedGasSponsorSignature); + }); + + it('should verify signature serialization format', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.recipients(testData.recipients); + txBuilder.paymentObjects(testData.paymentObjects); + txBuilder.gasData(testData.gasData); + + const tx = (await txBuilder.build()) as TransferTransaction; + + // Add signature + tx.addSignature(testData.testSignature.publicKey, testData.testSignature.signature); + + // Rebuild to trigger serialization + await tx.build(); + + // Decode and verify format: 0x00 + signature (64 bytes) + pubkey (32 bytes) = 97 bytes + const decoded = Buffer.from(tx.serializedSignature!, 'base64'); + + // Should be 97 bytes total (1 prefix + 64 signature + 32 pubkey) + decoded.length.should.equal(97); + + // First byte should be 0x00 + decoded[0].should.equal(0x00); + + // Next 64 bytes should be the signature + const signatureBytes = decoded.slice(1, 65); + signatureBytes.toString('hex').should.equal(testData.testSignature.signature.toString('hex')); + + // Last 32 bytes should be the public key + const pubKeyBytes = decoded.slice(65); + pubKeyBytes.toString('hex').should.equal(testData.testSignature.publicKey.pub); + }); + }); });