From 9a673af33fc700fd6a8b9ede41413db2a0d20803 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Wed, 26 Aug 2026 10:53:31 +0000 Subject: [PATCH 1/6] fix(sdk-coin-flrp): guard against credential corruption Harden FLRP transaction credential handling by preserving the distinction\nbetween missing and empty credentials, reporting only signatures shared\nby every credential, and rejecting incomplete credential sets before\nbroadcast. This prevents parsed partially-signed transactions from\nregenerating placeholders or broadcasting invalid signatures.\n\nTicket: CECHO-2035 Session-Id: e9ac6d30-ad79-4eed-a11e-b4a790a58c02 Task-Id: 9881dc02-2bf1-4178-aeea-d6704ec0d405 --- modules/sdk-coin-flrp/src/lib/transaction.ts | 61 ++++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 959d964342..35529733ab 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -42,6 +42,19 @@ function isEmptySignature(signature: string): boolean { return !!signature && utils.removeHexPrefix(signature).startsWith(''.padStart(90, '0')); } +/** + * Checks whether an empty signature contains an address placeholder. + * A real signature alongside one means signing is incomplete. + */ +function isAddressPlaceholder(signature: string): boolean { + if (!isEmptySignature(signature)) { + return false; + } + const stripped = utils.removeHexPrefix(signature); + const suffix = stripped.substring(90); + return suffix.length > 0 && suffix !== ''.padStart(suffix.length, '0'); +} + /** * Interface for signature slot checking */ @@ -126,10 +139,25 @@ export class Transaction extends BaseTransaction { } get signature(): string[] { - if (!this.hasCredentials) { + if (!this.credentials || this.credentials.length === 0) { return []; } - return this.credentials[0].getSignatures().filter((s) => !isEmptySignature(s)); + + // A signature is complete only when it is present in every credential. + let intersection: Set | null = null; + for (const credential of this.credentials) { + const signatures = new Set(credential.getSignatures().filter((s) => !isEmptySignature(s))); + if (intersection === null) { + intersection = signatures; + } else { + for (const signature of intersection) { + if (!signatures.has(signature)) { + intersection.delete(signature); + } + } + } + } + return intersection ? [...intersection] : []; } get credentials(): Credential[] { @@ -137,7 +165,7 @@ export class Transaction extends BaseTransaction { } get hasCredentials(): boolean { - return this.credentials !== undefined && this.credentials.length > 0; + return this.credentials != null; } /** @inheritdoc */ @@ -153,7 +181,7 @@ export class Transaction extends BaseTransaction { if (!this._flareTransaction) { throw new InvalidTransactionError('empty transaction to sign'); } - if (!this.hasCredentials) { + if (!this.credentials || this.credentials.length === 0) { throw new InvalidTransactionError('empty credentials to sign'); } @@ -277,7 +305,30 @@ export class Transaction extends BaseTransaction { if (!this._flareTransaction) { throw new InvalidTransactionError('Empty transaction data'); } - // If we have the original raw signed bytes, use them directly to preserve exact format + const credentials = (this._flareTransaction as UnsignedTx).credentials; + if (credentials != null && credentials.length === 0) { + throw new InvalidTransactionError('transaction has no credentials -- cannot broadcast'); + } + if (credentials) { + let hasRealSignature = false; + let hasAddressPlaceholder = false; + for (const credential of credentials) { + for (const signature of credential.getSignatures()) { + if (isEmptySignature(signature)) { + hasAddressPlaceholder ||= isAddressPlaceholder(signature); + } else { + hasRealSignature = true; + } + } + } + if (hasRealSignature && hasAddressPlaceholder) { + throw new InvalidTransactionError( + 'transaction has a real ECDSA alongside an address placeholder (r=0): incomplete signing detected, refusing broadcast' + ); + } + } + + // If we have the original raw signed bytes, use them directly to preserve exact format. if (this._rawSignedBytes) { return FlareUtils.bufferToHex(this._rawSignedBytes); } From 9d12ddc823f02ed99defa96dca640fd1a20aa9a1 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Wed, 26 Aug 2026 11:08:31 +0000 Subject: [PATCH 2/6] test(sdk-coin-flrp): cover credential broadcast guards Add FLRP regression coverage for established empty credentials and mixed real-signature/address-placeholder states. These cases ensure the credential hardening remains effective against the partial-signing paths that caused invalid broadcast signatures.\n\nTicket: CECHO-2035 Session-Id: e9ac6d30-ad79-4eed-a11e-b4a790a58c02 Task-Id: 9881dc02-2bf1-4178-aeea-d6704ec0d405 --- .../test/unit/lib/exportInPTxBuilder.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 69ab74417a..a83e2ccc65 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -560,3 +560,31 @@ describe('Flrp Export In P Tx Builder', () => { }); }); }); + +describe('FLRP credential guard regression', () => { + const coinConfig = coins.get('tflrp'); + const factory = new TransactionBuilderFactory(coinConfig); + + it('treats an established empty credential array as credentials', async () => { + const tx = (await factory.from(EXPORT_IN_P.fullSigntxHex).build()) as Transaction; + const flareTx = tx.getFlareTransaction() as UnsignedTx; + flareTx.credentials = []; + + tx.hasCredentials.should.be.true(); + assert.throws(() => tx.toBroadcastFormat(), /transaction has no credentials/); + }); + + it('rejects a real signature alongside an address placeholder', async () => { + const tx = (await factory.from(EXPORT_IN_P.fullSigntxHex).build()) as Transaction; + const flareTx = tx.getFlareTransaction() as UnsignedTx; + const credentials = flareTx.credentials; + credentials.length.should.be.greaterThan(0); + + const placeholder = Buffer.from(''.padStart(90, '0') + '00'.repeat(20), 'hex'); + credentials[0].setSignature(0, placeholder); + assert.throws( + () => tx.toBroadcastFormat(), + /real ECDSA alongside an address placeholder \(r=0\)/ + ); + }); +}); From c45e4ab829717c133a8a7ccc326836a42d58daae Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Wed, 26 Aug 2026 11:09:23 +0000 Subject: [PATCH 3/6] test(sdk-coin-flrp): fix credential guard fixture reference Use the existing export fixture alias in the new regression tests so the FLRP package type-checks and the guard coverage can execute.\n\nTicket: CECHO-2035 Session-Id: e9ac6d30-ad79-4eed-a11e-b4a790a58c02 Task-Id: 9881dc02-2bf1-4178-aeea-d6704ec0d405 --- modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index a83e2ccc65..4d168cdc94 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -566,7 +566,7 @@ describe('FLRP credential guard regression', () => { const factory = new TransactionBuilderFactory(coinConfig); it('treats an established empty credential array as credentials', async () => { - const tx = (await factory.from(EXPORT_IN_P.fullSigntxHex).build()) as Transaction; + const tx = (await factory.from(testData.fullSigntxHex).build()) as Transaction; const flareTx = tx.getFlareTransaction() as UnsignedTx; flareTx.credentials = []; @@ -575,12 +575,12 @@ describe('FLRP credential guard regression', () => { }); it('rejects a real signature alongside an address placeholder', async () => { - const tx = (await factory.from(EXPORT_IN_P.fullSigntxHex).build()) as Transaction; + const tx = (await factory.from(testData.fullSigntxHex).build()) as Transaction; const flareTx = tx.getFlareTransaction() as UnsignedTx; const credentials = flareTx.credentials; credentials.length.should.be.greaterThan(0); - const placeholder = Buffer.from(''.padStart(90, '0') + '00'.repeat(20), 'hex'); + const placeholder = Buffer.from(''.padStart(90, '0') + '11'.repeat(20), 'hex'); credentials[0].setSignature(0, placeholder); assert.throws( () => tx.toBroadcastFormat(), From 395f9533f50dda4c063107951c0fee94d64a6362 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Wed, 26 Aug 2026 11:09:53 +0000 Subject: [PATCH 4/6] style(sdk-coin-flrp): format credential guard test Format the regression assertion according to the package lint rules so the new credential guard test passes the repository's Prettier check.\n\nTicket: CECHO-2035 Session-Id: e9ac6d30-ad79-4eed-a11e-b4a790a58c02 Task-Id: 9881dc02-2bf1-4178-aeea-d6704ec0d405 --- modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 4d168cdc94..d61985b469 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -582,9 +582,6 @@ describe('FLRP credential guard regression', () => { const placeholder = Buffer.from(''.padStart(90, '0') + '11'.repeat(20), 'hex'); credentials[0].setSignature(0, placeholder); - assert.throws( - () => tx.toBroadcastFormat(), - /real ECDSA alongside an address placeholder \(r=0\)/ - ); + assert.throws(() => tx.toBroadcastFormat(), /real ECDSA alongside an address placeholder \(r=0\)/); }); }); From 3f69f67f586621a07c80f56afadcf56ba8a2b434 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Thu, 10 Sep 2026 06:36:17 +0000 Subject: [PATCH 5/6] test(sdk-coin-flrp): harden credential guard regressions Cover established empty credentials, incomplete signature intersections, and signing rejection so malformed transactions cannot be regenerated or broadcast. Ticket: CECHO-2035 Session-Id: 783e6dc2-26b9-421c-bab5-aa102e1fad15 Task-Id: 927eb335-15a2-4c41-ba21-bc6b6dc8deb2 --- modules/sdk-coin-flrp/src/lib/transaction.ts | 2 +- .../test/unit/lib/exportInPTxBuilder.ts | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 35529733ab..0aa7bcc0d2 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -307,7 +307,7 @@ export class Transaction extends BaseTransaction { } const credentials = (this._flareTransaction as UnsignedTx).credentials; if (credentials != null && credentials.length === 0) { - throw new InvalidTransactionError('transaction has no credentials -- cannot broadcast'); + throw new InvalidTransactionError('transaction has no credentials — cannot broadcast'); } if (credentials) { let hasRealSignature = false; diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index d61985b469..45f601559d 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -574,6 +574,38 @@ describe('FLRP credential guard regression', () => { assert.throws(() => tx.toBroadcastFormat(), /transaction has no credentials/); }); + it('does not regenerate credentials when an established array is empty', async () => { + const builder = factory.from(testData.fullSigntxHex) as any; + const internalTx = builder.transaction as Transaction; + (internalTx.getFlareTransaction() as UnsignedTx).credentials = []; + + const rebuilt = (await builder.build()) as Transaction; + (rebuilt.getFlareTransaction() as UnsignedTx).credentials.length.should.equal(0); + }); + + it('rejects signing when credentials are established but empty', async () => { + const builder = factory.from(testData.fullSigntxHex) as any; + const internalTx = builder.transaction as Transaction; + (internalTx.getFlareTransaction() as UnsignedTx).credentials = []; + builder.sign({ key: testData.privateKeys[0] }); + + await builder.build().should.be.rejectedWith('empty credentials to sign'); + }); + + it('intersects signatures across every credential', async () => { + const tx = (await factory.from(testData.fullSigntxHex).build()) as Transaction; + const credentials = (tx.getFlareTransaction() as UnsignedTx).credentials; + credentials.length.should.be.greaterThan(1); + tx.signature.length.should.equal(2); + + const signatures = credentials[1].getSignatures(); + const secondSignatureIndex = signatures.findIndex((signature) => !signature.startsWith('0'.repeat(90))); + secondSignatureIndex.should.be.greaterThanOrEqual(0); + credentials[1].setSignature(secondSignatureIndex, Buffer.from('0'.repeat(130), 'hex')); + + tx.signature.length.should.equal(1); + }); + it('rejects a real signature alongside an address placeholder', async () => { const tx = (await factory.from(testData.fullSigntxHex).build()) as Transaction; const flareTx = tx.getFlareTransaction() as UnsignedTx; From f036f2a36638e4fc8e76252c87ea7abf5afbb6d2 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Thu, 10 Sep 2026 06:36:48 +0000 Subject: [PATCH 6/6] fix(sdk-coin-flrp): guard external signatures on empty credentials Use the credential array directly so established empty credentials fail consistently across signing APIs instead of reaching slot iteration. Ticket: CECHO-2035 Session-Id: 783e6dc2-26b9-421c-bab5-aa102e1fad15 Task-Id: 927eb335-15a2-4c41-ba21-bc6b6dc8deb2 --- modules/sdk-coin-flrp/src/lib/transaction.ts | 2 +- .../sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 0aa7bcc0d2..4f4ec56439 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -278,7 +278,7 @@ export class Transaction extends BaseTransaction { if (!this._flareTransaction) { throw new InvalidTransactionError('empty transaction to sign'); } - if (!this.hasCredentials) { + if (!this.credentials || this.credentials.length === 0) { throw new InvalidTransactionError('empty credentials to sign'); } const unsignedTx = this._flareTransaction as UnsignedTx; diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 45f601559d..5ba0e16090 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -606,6 +606,16 @@ describe('FLRP credential guard regression', () => { tx.signature.length.should.equal(1); }); + it('rejects external signatures when credentials are established but empty', async () => { + const tx = (await factory.from(testData.fullSigntxHex).build()) as Transaction; + (tx.getFlareTransaction() as UnsignedTx).credentials = []; + + assert.throws( + () => tx.addExternalSignature(new Uint8Array(65)), + /empty credentials to sign/ + ); + }); + it('rejects a real signature alongside an address placeholder', async () => { const tx = (await factory.from(testData.fullSigntxHex).build()) as Transaction; const flareTx = tx.getFlareTransaction() as UnsignedTx;