Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion chaincode/src/contracts/GalaContract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
}
});
Expand Down
56 changes: 52 additions & 4 deletions chaincode/src/contracts/GalaTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -112,6 +112,48 @@ function isArrayOut<Out>(x: OutType<Out> | OutArrType<Out> | 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<void> {
if (!uniqueKey) {
return;
}

try {
await UniqueTransactionService.ensureUniqueTransaction(ctx, uniqueKey);
} catch (e) {
ChainError.recover(e, UniqueTransactionConflictError);
}
}

function Submit<In extends SubmitCallDTO, Out>(
options: GalaSubmitOptions<In, Out>
): GalaTransactionDecoratorFunction {
Expand Down Expand Up @@ -192,6 +234,8 @@ function GalaTransaction<In extends ChainCallDTO, Out>(

// 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);
Expand All @@ -217,6 +261,7 @@ function GalaTransaction<In extends ChainCallDTO, Out>(
dtoPlain as string | Record<string, unknown>,
validationOptions
);
uniqueKey = dto?.uniqueKey;

// Note using Date.now() instead of ctx.txUnixTime which is provided client-side.
if (dto?.dtoExpiresAt && dto.dtoExpiresAt < Date.now()) {
Expand Down Expand Up @@ -252,12 +297,11 @@ function GalaTransaction<In extends ChainCallDTO, Out>(
// 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);
}
});

Expand Down Expand Up @@ -291,6 +335,10 @@ function GalaTransaction<In extends ChainCallDTO, Out>(
}
);
} catch (err) {
if (options.enforceUniqueKey && !isUniqueTransactionConflict(err)) {
await persistUniqueKeyOnFailure(ctx, uniqueKey ?? readUniqueKeyFromPlain(dtoPlain));
}

const chainError = ChainError.from(err);
ctx.otel.recordError(err);

Expand Down
45 changes: 44 additions & 1 deletion chaincode/src/services/UniqueTransactionService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);
Expand Down
Loading