diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 959d964342..4f4ec56439 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'); } @@ -250,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; @@ -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); } diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 69ab74417a..5ba0e16090 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -560,3 +560,70 @@ 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(testData.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('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 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; + const credentials = flareTx.credentials; + credentials.length.should.be.greaterThan(0); + + 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\)/); + }); +});