From 3e54cacd0025ff5817bffc6e1c9be40965edb528 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 17 Sep 2026 12:24:40 +0200 Subject: [PATCH 1/4] Fix: Persist uniqueKey when authentication fails Consume uniqueKey before authenticate so a failed submit still spends the key. Co-authored-by: Cursor --- chaincode/src/contracts/GalaTransaction.ts | 22 ++++++++--------- .../services/UniqueTransactionService.spec.ts | 24 ++++++++++++++++++- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/chaincode/src/contracts/GalaTransaction.ts b/chaincode/src/contracts/GalaTransaction.ts index 5a8841c88a..412053fa69 100644 --- a/chaincode/src/contracts/GalaTransaction.ts +++ b/chaincode/src/contracts/GalaTransaction.ts @@ -223,6 +223,17 @@ function GalaTransaction( throw new ExpiredError(`DTO expired at ${new Date(dto.dtoExpiresAt).toISOString()}`); } + // Record uniqueKey before auth so authenticate/authorize failures + // still consume the key (flushed on error by afterTransaction). + if (options.enforceUniqueKey) { + if (dto?.uniqueKey) { + await UniqueTransactionService.ensureUniqueTransaction(ctx, dto.uniqueKey); + } else { + const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; + throw new RuntimeError(message); + } + } + await ctx.otel.send("gala.authorize", {}, async () => { // Authenticate the user if (ctx.isDryRun) { @@ -248,17 +259,6 @@ function GalaTransaction( // Authorize the user await authorize(ctx, options, dto); - - // Record uniqueKey before the handler so a later business failure - // still consumes the key (flushed on error by afterTransaction). - if (options.enforceUniqueKey) { - if (dto?.uniqueKey) { - await UniqueTransactionService.ensureUniqueTransaction(ctx, dto.uniqueKey); - } else { - const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; - throw new RuntimeError(message); - } - } }); const argArray: [GalaChainContext, In] | [GalaChainContext] = dto ? [ctx, dto] : [ctx]; diff --git a/chaincode/src/services/UniqueTransactionService.spec.ts b/chaincode/src/services/UniqueTransactionService.spec.ts index 2428ca82d5..d6f6cc065b 100644 --- a/chaincode/src/services/UniqueTransactionService.spec.ts +++ b/chaincode/src/services/UniqueTransactionService.spec.ts @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createValidDTO } from "@gala-chain/api"; +import { createValidDTO, signatures } from "@gala-chain/api"; import { TestChaincode, transactionError, transactionErrorKey, transactionSuccess } from "@gala-chain/test"; import TestGalaContract, { KVDto, SuperheroDto } from "../__test__/TestGalaContract"; @@ -49,6 +49,28 @@ describe("UniqueTransactionService", () => { expect(saveResponse).toEqual(transactionError()); }); + it("should consume uniqueKey when authentication fails", async () => { + // Given a signed submit DTO whose signature cannot recover a public key + const chaincode = new TestChaincode([TestGalaContract]); + const dto = await createValidDTO(SuperheroDto, { + name: "foo", + age: 2, + uniqueKey: "failed-auth-uk" + }); + dto.signature = signatures.getDERSignature( + dto, + signatures.normalizePrivateKey(signatures.genKeyPair().privateKey) + ); + + // When + const first = await chaincode.invoke("TestGalaContract:CreateSuperhero", dto.serialize()); + const second = await chaincode.invoke("TestGalaContract:CreateSuperhero", dto.serialize()); + + // Then the first attempt fails auth, but the uniqueKey is still consumed + expect(first).toEqual(transactionErrorKey("MISSING_SIGNER")); + expect(second).toEqual(transactionErrorKey("UNIQUE_TRANSACTION_CONFLICT")); + }); + it("should consume uniqueKey when the submit handler fails", async () => { // Given const chaincode = new TestChaincode([TestGalaContract]); From 0ab393d41a16af7d6ca95d8bca2b872bcf451868 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 17 Sep 2026 13:49:36 +0200 Subject: [PATCH 2/4] Fix: Consume uniqueKey before endpoint-specific DTO parse uniqueKey is global and the method is not in the signed payload, so spend it from the raw submit even when this endpoint rejects the DTO. Co-authored-by: Cursor --- chaincode/src/contracts/GalaContract.spec.ts | 4 +- chaincode/src/contracts/GalaTransaction.ts | 46 ++++++++++++++----- .../services/UniqueTransactionService.spec.ts | 18 ++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/chaincode/src/contracts/GalaContract.spec.ts b/chaincode/src/contracts/GalaContract.spec.ts index 75c44ecba2..4ac91891f6 100644 --- a/chaincode/src/contracts/GalaContract.spec.ts +++ b/chaincode/src/contracts/GalaContract.spec.ts @@ -403,7 +403,9 @@ describe("GalaContract.DryRun", () => { Message: "DTO validation failed: (1) isPositive: age must be a positive number" }, reads: { [callerProfileKey]: "" }, - writes: {}, + writes: { + [`\u0000UNTX\u0000${dto.uniqueKey}\u0000`]: expect.any(String) + }, deletes: {} } }); diff --git a/chaincode/src/contracts/GalaTransaction.ts b/chaincode/src/contracts/GalaTransaction.ts index 412053fa69..50c1c8da8d 100644 --- a/chaincode/src/contracts/GalaTransaction.ts +++ b/chaincode/src/contracts/GalaTransaction.ts @@ -112,6 +112,29 @@ function isArrayOut(x: OutType | OutArrType | undefined): x is Ou return typeof x === "object" && "arrayOf" in x; } +/** uniqueKey is a global nonce; read it from the raw submit, not the method DTO. */ +function readUniqueKeyFromPlain(dtoPlain: unknown): string | undefined { + if (dtoPlain == null) { + return undefined; + } + + let plain: unknown = dtoPlain; + if (typeof dtoPlain === "string") { + try { + plain = JSON.parse(dtoPlain); + } catch { + return undefined; + } + } + + if (typeof plain !== "object" || plain === null || Array.isArray(plain)) { + return undefined; + } + + const uniqueKey = (plain as { uniqueKey?: unknown }).uniqueKey; + return typeof uniqueKey === "string" && uniqueKey.length > 0 ? uniqueKey : undefined; +} + function Submit( options: GalaSubmitOptions ): GalaTransactionDecoratorFunction { @@ -204,6 +227,18 @@ function GalaTransaction( "gala.tx_type": options.type === GalaTransactionType.SUBMIT ? "SUBMIT" : "EVALUATE" }, async () => { + // uniqueKey is global and the method is not in the signed payload. + // Spend it from the raw submit before this endpoint's parse/auth. + if (options.enforceUniqueKey) { + const uniqueKey = readUniqueKeyFromPlain(dtoPlain); + if (uniqueKey) { + await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); + } else { + const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; + throw new RuntimeError(message); + } + } + // Parse & validate - may throw an exception const dtoClass = options.in ?? (ChainCallDTO as unknown as ClassConstructor>); const validationOptions = @@ -223,17 +258,6 @@ function GalaTransaction( throw new ExpiredError(`DTO expired at ${new Date(dto.dtoExpiresAt).toISOString()}`); } - // Record uniqueKey before auth so authenticate/authorize failures - // still consume the key (flushed on error by afterTransaction). - if (options.enforceUniqueKey) { - if (dto?.uniqueKey) { - await UniqueTransactionService.ensureUniqueTransaction(ctx, dto.uniqueKey); - } else { - const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; - throw new RuntimeError(message); - } - } - await ctx.otel.send("gala.authorize", {}, async () => { // Authenticate the user if (ctx.isDryRun) { diff --git a/chaincode/src/services/UniqueTransactionService.spec.ts b/chaincode/src/services/UniqueTransactionService.spec.ts index d6f6cc065b..9235d346c1 100644 --- a/chaincode/src/services/UniqueTransactionService.spec.ts +++ b/chaincode/src/services/UniqueTransactionService.spec.ts @@ -49,6 +49,24 @@ describe("UniqueTransactionService", () => { expect(saveResponse).toEqual(transactionError()); }); + it("should consume uniqueKey when this endpoint rejects the DTO", async () => { + // Given a submit that fails CreateSuperhero validation but is valid for PutKv + const chaincode = new TestChaincode([TestGalaContract]); + const uniqueKey = "failed-parse-uk"; + + // When + const first = await chaincode.invoke("TestGalaContract:CreateSuperhero", JSON.stringify({ uniqueKey })); + const second = await chaincode.invoke( + "TestGalaContract:PutKv", + JSON.stringify({ uniqueKey, key: "should-not-persist", value: "robot" }) + ); + + // Then the first endpoint rejects the DTO, but the uniqueKey is still consumed + expect(first).toEqual(transactionErrorKey("DTO_VALIDATION_FAILED")); + expect(second).toEqual(transactionErrorKey("UNIQUE_TRANSACTION_CONFLICT")); + expect(chaincode.getStateAll()["should-not-persist"]).toBeUndefined(); + }); + it("should consume uniqueKey when authentication fails", async () => { // Given a signed submit DTO whose signature cannot recover a public key const chaincode = new TestChaincode([TestGalaContract]); From c636cf408450226a2f5324f7f1a532b00c8dbb2e Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 17 Sep 2026 16:34:54 +0200 Subject: [PATCH 3/4] Refactor: Resolve uniqueKey from the DTO, fall back on error Keep uniqueKey from the parsed DTO on the happy path. On throw, use that value or read it from the raw submit so a rejected endpoint still spends it. Co-authored-by: Cursor --- chaincode/src/contracts/GalaTransaction.ts | 52 ++++++++++++++-------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/chaincode/src/contracts/GalaTransaction.ts b/chaincode/src/contracts/GalaTransaction.ts index 50c1c8da8d..8409e34f5a 100644 --- a/chaincode/src/contracts/GalaTransaction.ts +++ b/chaincode/src/contracts/GalaTransaction.ts @@ -215,6 +215,9 @@ function GalaTransaction( // ALS = SERVER so nested batch EVALUATE does not parent to batch.operation. return ctx.otel.run(async () => { + let uniqueKey: string | undefined; + let uniqueKeySaved = false; + try { const metadata = [{ dto: dtoPlain }]; ctx?.logger?.logTimeline("Begin Transaction", loggingContext, metadata); @@ -227,18 +230,6 @@ function GalaTransaction( "gala.tx_type": options.type === GalaTransactionType.SUBMIT ? "SUBMIT" : "EVALUATE" }, async () => { - // uniqueKey is global and the method is not in the signed payload. - // Spend it from the raw submit before this endpoint's parse/auth. - if (options.enforceUniqueKey) { - const uniqueKey = readUniqueKeyFromPlain(dtoPlain); - if (uniqueKey) { - await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); - } else { - const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; - throw new RuntimeError(message); - } - } - // Parse & validate - may throw an exception const dtoClass = options.in ?? (ChainCallDTO as unknown as ClassConstructor>); const validationOptions = @@ -252,6 +243,7 @@ function GalaTransaction( dtoPlain as string | Record, validationOptions ); + uniqueKey = dto?.uniqueKey; // Note using Date.now() instead of ctx.txUnixTime which is provided client-side. if (dto?.dtoExpiresAt && dto.dtoExpiresAt < Date.now()) { @@ -283,6 +275,17 @@ function GalaTransaction( // Authorize the user await authorize(ctx, options, dto); + + // Record uniqueKey before the handler so a later business failure + // still consumes the key (flushed on error by afterTransaction). + if (options.enforceUniqueKey) { + if (!uniqueKey) { + const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; + throw new RuntimeError(message); + } + await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); + uniqueKeySaved = true; + } }); const argArray: [GalaChainContext, In] | [GalaChainContext] = dto ? [ctx, dto] : [ctx]; @@ -315,14 +318,27 @@ function GalaTransaction( } ); } catch (err) { - const chainError = ChainError.from(err); - ctx.otel.recordError(err); + let resultErr = err as Error; + if (options.enforceUniqueKey && !uniqueKeySaved) { + uniqueKey = uniqueKey ?? readUniqueKeyFromPlain(dtoPlain); + if (uniqueKey) { + try { + await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); + uniqueKeySaved = true; + } catch (persistErr) { + resultErr = persistErr as Error; + } + } + } + + const chainError = ChainError.from(resultErr); + ctx.otel.recordError(resultErr); if (ctx.logger) { chainError.logWarn(ctx.logger); - ctx.logger.logTimeline("Failed Transaction", loggingContext, [dtoPlain], err); - ctx.logger.debug(err.message); - ctx.logger.debug(err.stack); + ctx.logger.logTimeline("Failed Transaction", loggingContext, [dtoPlain], resultErr); + ctx.logger.debug(resultErr.message); + ctx.logger.debug(resultErr.stack ?? ""); } // if external chaincode call succeeded, but the remaining part of the @@ -338,7 +354,7 @@ function GalaTransaction( // Note: since it does not end with an exception, failed transactions are also saved // on chain in transaction history. - return GalaChainResponse.Error(err as Error); + return GalaChainResponse.Error(resultErr); } }); }; From 2fb50073112b575c936c3efed908f65a928ef927 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 17 Sep 2026 16:58:35 +0200 Subject: [PATCH 4/4] Refactor: Persist uniqueKey on failure without a saved flag Skip the catch persist when the error is already a uniqueKey conflict, and keep that write in a helper. Co-authored-by: Cursor --- chaincode/src/contracts/GalaTransaction.ts | 48 +++++++++++-------- .../services/UniqueTransactionService.spec.ts | 5 +- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/chaincode/src/contracts/GalaTransaction.ts b/chaincode/src/contracts/GalaTransaction.ts index 8409e34f5a..d3d30e3800 100644 --- a/chaincode/src/contracts/GalaTransaction.ts +++ b/chaincode/src/contracts/GalaTransaction.ts @@ -35,7 +35,7 @@ import { import { Object as DTOObject, Transaction } from "fabric-contract-api"; import { inspect } from "util"; -import { UniqueTransactionService } from "../services"; +import { UniqueTransactionConflictError, UniqueTransactionService } from "../services"; import { GalaChainContext } from "../types"; import { GalaContract } from "./GalaContract"; import { updateApi } from "./GalaContractApi"; @@ -135,6 +135,25 @@ function readUniqueKeyFromPlain(dtoPlain: unknown): string | undefined { return typeof uniqueKey === "string" && uniqueKey.length > 0 ? uniqueKey : undefined; } +function isUniqueTransactionConflict(err: unknown): boolean { + return ChainError.matches(ChainError.from(err as object), UniqueTransactionConflictError); +} + +async function persistUniqueKeyOnFailure( + ctx: GalaChainContext, + uniqueKey: string | undefined +): Promise { + if (!uniqueKey) { + return; + } + + try { + await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); + } catch (e) { + ChainError.recover(e, UniqueTransactionConflictError); + } +} + function Submit( options: GalaSubmitOptions ): GalaTransactionDecoratorFunction { @@ -216,7 +235,6 @@ function GalaTransaction( // ALS = SERVER so nested batch EVALUATE does not parent to batch.operation. return ctx.otel.run(async () => { let uniqueKey: string | undefined; - let uniqueKeySaved = false; try { const metadata = [{ dto: dtoPlain }]; @@ -284,7 +302,6 @@ function GalaTransaction( throw new RuntimeError(message); } await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); - uniqueKeySaved = true; } }); @@ -318,27 +335,18 @@ function GalaTransaction( } ); } catch (err) { - let resultErr = err as Error; - if (options.enforceUniqueKey && !uniqueKeySaved) { - uniqueKey = uniqueKey ?? readUniqueKeyFromPlain(dtoPlain); - if (uniqueKey) { - try { - await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); - uniqueKeySaved = true; - } catch (persistErr) { - resultErr = persistErr as Error; - } - } + if (options.enforceUniqueKey && !isUniqueTransactionConflict(err)) { + await persistUniqueKeyOnFailure(ctx, uniqueKey ?? readUniqueKeyFromPlain(dtoPlain)); } - const chainError = ChainError.from(resultErr); - ctx.otel.recordError(resultErr); + const chainError = ChainError.from(err); + ctx.otel.recordError(err); if (ctx.logger) { chainError.logWarn(ctx.logger); - ctx.logger.logTimeline("Failed Transaction", loggingContext, [dtoPlain], resultErr); - ctx.logger.debug(resultErr.message); - ctx.logger.debug(resultErr.stack ?? ""); + ctx.logger.logTimeline("Failed Transaction", loggingContext, [dtoPlain], err); + ctx.logger.debug(err.message); + ctx.logger.debug(err.stack); } // if external chaincode call succeeded, but the remaining part of the @@ -354,7 +362,7 @@ function GalaTransaction( // Note: since it does not end with an exception, failed transactions are also saved // on chain in transaction history. - return GalaChainResponse.Error(resultErr); + return GalaChainResponse.Error(err as Error); } }); }; diff --git a/chaincode/src/services/UniqueTransactionService.spec.ts b/chaincode/src/services/UniqueTransactionService.spec.ts index 9235d346c1..a51da454f6 100644 --- a/chaincode/src/services/UniqueTransactionService.spec.ts +++ b/chaincode/src/services/UniqueTransactionService.spec.ts @@ -82,7 +82,10 @@ describe("UniqueTransactionService", () => { // When const first = await chaincode.invoke("TestGalaContract:CreateSuperhero", dto.serialize()); - const second = await chaincode.invoke("TestGalaContract:CreateSuperhero", dto.serialize()); + const second = await chaincode.invoke( + "TestGalaContract:PutKv", + JSON.stringify({ uniqueKey: dto.uniqueKey, key: "should-not-persist", value: "robot" }) + ); // Then the first attempt fails auth, but the uniqueKey is still consumed expect(first).toEqual(transactionErrorKey("MISSING_SIGNER"));