diff --git a/chaincode/src/contracts/GalaContract.spec.ts b/chaincode/src/contracts/GalaContract.spec.ts index a4c7abd9d2..fcb40c7826 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 750482789c..96c1fb8b10 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"; @@ -112,6 +112,48 @@ 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 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 { @@ -192,6 +234,8 @@ function GalaTransaction( // ALS = SERVER so nested batch EVALUATE does not parent to batch.operation. return ctx.otel.run(async () => { + let uniqueKey: string | undefined; + try { const metadata = [{ dto: dtoPlain }]; ctx?.logger?.logTimeline("Begin Transaction", loggingContext, metadata); @@ -217,6 +261,7 @@ function GalaTransaction( dtoPlain as string | Record, validationOptions ); + uniqueKey = dto?.uniqueKey; if (dto?.dtoExpiresAt !== undefined && dto.dtoExpiresAt < ctx.txUnixTime) { throw new ExpiredError(`DTO expired at ${new Date(dto.dtoExpiresAt).toISOString()}`); @@ -251,12 +296,11 @@ function GalaTransaction( // 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 { + if (!uniqueKey) { const message = `Missing uniqueKey in transaction dto for method '${method.name}'`; throw new RuntimeError(message); } + await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey); } }); @@ -290,6 +334,10 @@ function GalaTransaction( } ); } catch (err) { + if (options.enforceUniqueKey && !isUniqueTransactionConflict(err)) { + await persistUniqueKeyOnFailure(ctx, uniqueKey ?? readUniqueKeyFromPlain(dtoPlain)); + } + const chainError = ChainError.from(err); ctx.otel.recordError(err); diff --git a/chaincode/src/services/UniqueTransactionService.spec.ts b/chaincode/src/services/UniqueTransactionService.spec.ts index 2428ca82d5..a51da454f6 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,49 @@ 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]); + 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: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")); + expect(second).toEqual(transactionErrorKey("UNIQUE_TRANSACTION_CONFLICT")); + }); + it("should consume uniqueKey when the submit handler fails", async () => { // Given const chaincode = new TestChaincode([TestGalaContract]);