PR A: 12-state transaction machine + issueInstrument step + SoD matrix #5
9
orchestrator/jest.config.js
Normal file
9
orchestrator/jest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/tests"],
|
||||
testMatch: ["**/*.test.ts"],
|
||||
testPathIgnorePatterns: ["/node_modules/", "/integration/", "/chaos/", "/load/"],
|
||||
moduleFileExtensions: ["ts", "js", "json"],
|
||||
};
|
||||
@@ -25,11 +25,17 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"jest": "^30.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
48
orchestrator/src/db/migrations/002_transaction_state.ts
Normal file
48
orchestrator/src/db/migrations/002_transaction_state.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { query } from "../postgres";
|
||||
import { TRANSACTION_STATES } from "../../types/transactionState";
|
||||
|
||||
/**
|
||||
* Migration 002 — workflow-level transaction state.
|
||||
*
|
||||
* Architecture note §8 (12-state machine) + §9 (transition table).
|
||||
*
|
||||
* Adds:
|
||||
* - plans.transaction_state column (CHECK-constrained)
|
||||
* - transaction_state_transitions append-only table
|
||||
*/
|
||||
export async function up() {
|
||||
const states = TRANSACTION_STATES.map((s) => `'${s}'`).join(",");
|
||||
|
||||
await query(
|
||||
`ALTER TABLE plans
|
||||
ADD COLUMN IF NOT EXISTS transaction_state VARCHAR(32) NOT NULL
|
||||
DEFAULT 'DRAFT'
|
||||
CHECK (transaction_state IN (${states}))`,
|
||||
);
|
||||
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS transaction_state_transitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plan_id UUID NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
|
||||
from_state VARCHAR(32),
|
||||
to_state VARCHAR(32) NOT NULL CHECK (to_state IN (${states})),
|
||||
reason TEXT,
|
||||
source_event_id UUID,
|
||||
actor VARCHAR(255) NOT NULL,
|
||||
actor_role VARCHAR(32) NOT NULL,
|
||||
signature TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
);
|
||||
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_tx_transitions_plan_id
|
||||
ON transaction_state_transitions(plan_id)`,
|
||||
);
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_tx_transitions_created_at
|
||||
ON transaction_state_transitions(created_at)`,
|
||||
);
|
||||
|
||||
console.log("Migration 002 applied: transaction_state + transitions table");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { up as up001 } from "./001_initial_schema";
|
||||
import { up as up002 } from "./002_transaction_state";
|
||||
|
||||
/**
|
||||
* Run all migrations
|
||||
@@ -6,10 +7,10 @@ import { up as up001 } from "./001_initial_schema";
|
||||
export async function runMigration() {
|
||||
try {
|
||||
await up001();
|
||||
console.log("✅ All migrations completed");
|
||||
await up002();
|
||||
console.log("All migrations completed");
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error);
|
||||
console.error("Migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,52 @@ function validateStep(step: PlanStep, index: number): string[] {
|
||||
errors.push(`Step ${index + 1}: Invalid pay step (asset/amount/IBAN missing)`);
|
||||
}
|
||||
break;
|
||||
case "issueInstrument": {
|
||||
const inst = step.instrument;
|
||||
if (!inst) {
|
||||
errors.push(`Step ${index + 1}: issueInstrument step missing instrument terms`);
|
||||
break;
|
||||
}
|
||||
const required: Array<keyof typeof inst> = [
|
||||
"applicant",
|
||||
"issuingBankBIC",
|
||||
"beneficiaryBankBIC",
|
||||
"beneficiaryName",
|
||||
"currency",
|
||||
"tenor",
|
||||
"expiryDate",
|
||||
"placeOfPresentation",
|
||||
"governingLaw",
|
||||
"templateRef",
|
||||
"templateHash",
|
||||
];
|
||||
for (const key of required) {
|
||||
if (!inst[key] || String(inst[key]).trim() === "") {
|
||||
errors.push(`Step ${index + 1}: instrument.${String(key)} is required`);
|
||||
}
|
||||
}
|
||||
if (!(inst.amount > 0)) {
|
||||
errors.push(`Step ${index + 1}: instrument.amount must be > 0`);
|
||||
}
|
||||
if (inst.currency && !/^[A-Z]{3}$/.test(inst.currency)) {
|
||||
errors.push(`Step ${index + 1}: instrument.currency must be ISO 4217 (e.g. USD)`);
|
||||
}
|
||||
// BIC is 8 or 11 chars: 4 bank + 2 country + 2 location [+ 3 branch]
|
||||
const bicRe = /^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
|
||||
if (inst.issuingBankBIC && !bicRe.test(inst.issuingBankBIC)) {
|
||||
errors.push(`Step ${index + 1}: instrument.issuingBankBIC is not a valid BIC`);
|
||||
}
|
||||
if (inst.beneficiaryBankBIC && !bicRe.test(inst.beneficiaryBankBIC)) {
|
||||
errors.push(`Step ${index + 1}: instrument.beneficiaryBankBIC is not a valid BIC`);
|
||||
}
|
||||
if (inst.expiryDate && !/^\d{4}-\d{2}-\d{2}$/.test(inst.expiryDate)) {
|
||||
errors.push(`Step ${index + 1}: instrument.expiryDate must be YYYY-MM-DD`);
|
||||
}
|
||||
if (inst.templateHash && !/^[0-9a-fA-F]{64}$/.test(inst.templateHash)) {
|
||||
errors.push(`Step ${index + 1}: instrument.templateHash must be 64 hex chars (sha256)`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
||||
174
orchestrator/src/services/stateMachine.ts
Normal file
174
orchestrator/src/services/stateMachine.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Transaction state-machine service.
|
||||
*
|
||||
* Centralized enforcement of architecture note §9 (state-transition rules).
|
||||
* The coordinator, exception manager, and any operator action must route
|
||||
* through `transition()` so the transition table and segregation-of-duties
|
||||
* matrix are applied identically everywhere.
|
||||
*/
|
||||
|
||||
import { query, transaction as dbTransaction } from "../db/postgres";
|
||||
import {
|
||||
ALLOWED_TRANSITIONS,
|
||||
ROLE_FOR_TRANSITION,
|
||||
SOD_REQUIRED_TRANSITIONS,
|
||||
canTransition,
|
||||
type ActorRole,
|
||||
type TransactionState,
|
||||
} from "../types/transactionState";
|
||||
|
||||
export interface TransitionRequest {
|
||||
planId: string;
|
||||
from: TransactionState;
|
||||
to: TransactionState;
|
||||
actor: string;
|
||||
actorRole: ActorRole;
|
||||
reason?: string;
|
||||
sourceEventId?: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export class StateTransitionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code:
|
||||
| "illegal_transition"
|
||||
| "sod_violation"
|
||||
| "stale_from_state"
|
||||
| "terminal_state",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "StateTransitionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a state transition atomically: verify legality, enforce SoD,
|
||||
* update `plans.transaction_state`, and append a row to
|
||||
* `transaction_state_transitions`.
|
||||
*
|
||||
* Throws `StateTransitionError` if the transition is not legal or violates
|
||||
* segregation-of-duties.
|
||||
*/
|
||||
export async function transition(req: TransitionRequest): Promise<void> {
|
||||
if (!canTransition(req.from, req.to)) {
|
||||
throw new StateTransitionError(
|
||||
`Transition ${req.from} -> ${req.to} is not in the allowed table`,
|
||||
"illegal_transition",
|
||||
);
|
||||
}
|
||||
|
||||
const key = `${req.from}->${req.to}` as const;
|
||||
if (SOD_REQUIRED_TRANSITIONS.has(key)) {
|
||||
const requiredRole = ROLE_FOR_TRANSITION[key];
|
||||
if (req.actorRole !== requiredRole) {
|
||||
throw new StateTransitionError(
|
||||
`Transition ${key} requires role '${requiredRole}' but actor '${req.actor}' has role '${req.actorRole}'`,
|
||||
"sod_violation",
|
||||
);
|
||||
}
|
||||
// SoD: the actor executing the transition must not be the same as the
|
||||
// actor who drove the previous human-gated transition. We enforce this
|
||||
// at the coordinator level by looking at the transition log.
|
||||
const prior = await query<{ actor: string; actor_role: ActorRole }>(
|
||||
`SELECT actor, actor_role FROM transaction_state_transitions
|
||||
WHERE plan_id = $1
|
||||
AND actor_role IN ('approver','releaser','exception_manager')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[req.planId],
|
||||
);
|
||||
if (prior.length > 0 && prior[0].actor === req.actor) {
|
||||
throw new StateTransitionError(
|
||||
`SoD violation: actor '${req.actor}' already drove the previous gated transition`,
|
||||
"sod_violation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await dbTransaction(async (client) => {
|
||||
const current = await client.query<{ transaction_state: TransactionState }>(
|
||||
"SELECT transaction_state FROM plans WHERE plan_id = $1 FOR UPDATE",
|
||||
[req.planId],
|
||||
);
|
||||
if (current.rows.length === 0) {
|
||||
throw new StateTransitionError(
|
||||
`Plan ${req.planId} not found`,
|
||||
"stale_from_state",
|
||||
);
|
||||
}
|
||||
if (current.rows[0].transaction_state !== req.from) {
|
||||
throw new StateTransitionError(
|
||||
`Plan ${req.planId} is in state '${current.rows[0].transaction_state}', not '${req.from}'`,
|
||||
"stale_from_state",
|
||||
);
|
||||
}
|
||||
if (ALLOWED_TRANSITIONS[current.rows[0].transaction_state].length === 0) {
|
||||
throw new StateTransitionError(
|
||||
`Plan ${req.planId} is in terminal state '${current.rows[0].transaction_state}'`,
|
||||
"terminal_state",
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
"UPDATE plans SET transaction_state = $1, updated_at = CURRENT_TIMESTAMP WHERE plan_id = $2",
|
||||
[req.to, req.planId],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO transaction_state_transitions (
|
||||
plan_id, from_state, to_state, reason, source_event_id,
|
||||
actor, actor_role, signature
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
req.planId,
|
||||
req.from,
|
||||
req.to,
|
||||
req.reason ?? null,
|
||||
req.sourceEventId ?? null,
|
||||
req.actor,
|
||||
req.actorRole,
|
||||
req.signature ?? null,
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current transaction state for a plan.
|
||||
*/
|
||||
export async function getTransactionState(
|
||||
planId: string,
|
||||
): Promise<TransactionState | null> {
|
||||
const rows = await query<{ transaction_state: TransactionState }>(
|
||||
"SELECT transaction_state FROM plans WHERE plan_id = $1",
|
||||
[planId],
|
||||
);
|
||||
return rows.length > 0 ? rows[0].transaction_state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full state-transition history for a plan.
|
||||
*/
|
||||
export async function getTransitionHistory(
|
||||
planId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
from_state: TransactionState | null;
|
||||
to_state: TransactionState;
|
||||
reason: string | null;
|
||||
actor: string;
|
||||
actor_role: ActorRole;
|
||||
signature: string | null;
|
||||
source_event_id: string | null;
|
||||
created_at: Date;
|
||||
}>
|
||||
> {
|
||||
return await query(
|
||||
`SELECT from_state, to_state, reason, actor, actor_role, signature,
|
||||
source_event_id, created_at
|
||||
FROM transaction_state_transitions
|
||||
WHERE plan_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[planId],
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,91 @@
|
||||
/**
|
||||
* Canonical data objects for the multi-layer atomic settlement architecture.
|
||||
*
|
||||
* A Plan models a single workflow-level atomic transaction composed of
|
||||
* multiple legs (DLT borrow/swap/repay, fiat payment, banking instrument
|
||||
* issuance). The combination must commit or abort as one unit.
|
||||
*/
|
||||
|
||||
import type { TransactionState } from "./transactionState";
|
||||
|
||||
export type PlanStepType = "borrow" | "swap" | "repay" | "pay" | "issueInstrument";
|
||||
|
||||
export interface BeneficiaryCoordinates {
|
||||
/** ISO 20022 / SEPA IBAN */
|
||||
IBAN?: string;
|
||||
/** BIC / SWIFT code of the beneficiary bank */
|
||||
BIC?: string;
|
||||
/** Beneficiary legal name */
|
||||
name?: string;
|
||||
/** Optional beneficiary bank legal name (for FI credit transfers) */
|
||||
bankName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instrument-leg fields — used by `type: "issueInstrument"` steps.
|
||||
*
|
||||
* Based on the Emirates Islamic beneficiary-format SBLC / MT760 template.
|
||||
* Each field corresponds to a MT760 / UCP 600 concept:
|
||||
*
|
||||
* - applicant MT760 field 50
|
||||
* - issuingBankBIC MT760 sender / field 52a
|
||||
* - beneficiaryBankBIC MT760 field 57a (advising bank)
|
||||
* - beneficiaryName MT760 field 59
|
||||
* - beneficiaryAccount MT760 field 59 (secondary)
|
||||
* - amount + currency MT760 field 32B
|
||||
* - tenor MT760 field 42C (e.g. "90D", "1Y")
|
||||
* - expiryDate MT760 field 31D (YYYY-MM-DD)
|
||||
* - placeOfPresentation MT760 field 78 / 49
|
||||
* - governingLaw MT760 field 40E (e.g. "URDG 758", "UCP 600", "ISP98")
|
||||
* - templateRef + templateHash pointer + integrity hash of the agreed text
|
||||
*/
|
||||
export interface InstrumentTerms {
|
||||
applicant: string;
|
||||
issuingBankBIC: string;
|
||||
beneficiaryBankBIC: string;
|
||||
beneficiaryName: string;
|
||||
beneficiaryAccount?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
tenor: string;
|
||||
expiryDate: string;
|
||||
placeOfPresentation: string;
|
||||
governingLaw: string;
|
||||
templateRef: string;
|
||||
/** SHA-256 of the agreed instrument text, hex-encoded without 0x prefix. */
|
||||
templateHash: string;
|
||||
}
|
||||
|
||||
export interface PlanStep {
|
||||
type: PlanStepType;
|
||||
asset?: string;
|
||||
amount: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
collateralRef?: string;
|
||||
beneficiary?: BeneficiaryCoordinates;
|
||||
/** Populated iff `type === "issueInstrument"`. */
|
||||
instrument?: InstrumentTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Participant entry in the registry. Each transaction binds at least
|
||||
* one role per participant. Used for segregation-of-duties enforcement
|
||||
* on state transitions.
|
||||
*/
|
||||
export interface Participant {
|
||||
id: string;
|
||||
role:
|
||||
| "applicant"
|
||||
| "issuing_bank"
|
||||
| "beneficiary_bank"
|
||||
| "beneficiary"
|
||||
| "coordinator"
|
||||
| "observer";
|
||||
lei?: string;
|
||||
did?: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
plan_id?: string;
|
||||
creator: string;
|
||||
@@ -7,20 +95,10 @@ export interface Plan {
|
||||
signature?: string;
|
||||
plan_hash?: string;
|
||||
created_at?: string;
|
||||
/** Legacy execution status (pending | complete | aborted). */
|
||||
status?: string;
|
||||
/** Full 12-state workflow state (architecture note §8). */
|
||||
transaction_state?: TransactionState;
|
||||
/** Optional participant registry. */
|
||||
participants?: Participant[];
|
||||
}
|
||||
|
||||
export interface PlanStep {
|
||||
type: "borrow" | "swap" | "repay" | "pay";
|
||||
asset?: string;
|
||||
amount: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
collateralRef?: string;
|
||||
beneficiary?: {
|
||||
IBAN?: string;
|
||||
BIC?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
87
orchestrator/src/types/transactionState.ts
Normal file
87
orchestrator/src/types/transactionState.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Transaction state machine — architecture note §8–§9.
|
||||
*
|
||||
* Workflow-level atomicity is enforced by constraining the plan lifecycle to
|
||||
* this set of states and this transition table. The coordinator and the
|
||||
* database CHECK constraint both reference this module so the values are
|
||||
* source-of-truth identical.
|
||||
*/
|
||||
|
||||
export const TRANSACTION_STATES = [
|
||||
"DRAFT",
|
||||
"INITIATED",
|
||||
"PRECONDITIONS_PENDING",
|
||||
"READY_FOR_PREPARE",
|
||||
"PREPARED",
|
||||
"EXECUTING",
|
||||
"PARTIALLY_EXECUTED",
|
||||
"VALIDATING",
|
||||
"COMMITTED",
|
||||
"ABORTED",
|
||||
"UNWIND_PENDING",
|
||||
"CLOSED",
|
||||
] as const;
|
||||
|
||||
export type TransactionState = (typeof TRANSACTION_STATES)[number];
|
||||
|
||||
export const TERMINAL_STATES: ReadonlySet<TransactionState> = new Set(["CLOSED"]);
|
||||
|
||||
/**
|
||||
* Architecture note §9.1 — permitted high-level transitions.
|
||||
*
|
||||
* Keys are `from` states; values are the set of legal `to` states.
|
||||
* Any transition not listed here must be rejected.
|
||||
*/
|
||||
export const ALLOWED_TRANSITIONS: Readonly<Record<TransactionState, ReadonlyArray<TransactionState>>> = {
|
||||
DRAFT: ["INITIATED"],
|
||||
INITIATED: ["PRECONDITIONS_PENDING"],
|
||||
PRECONDITIONS_PENDING: ["READY_FOR_PREPARE", "ABORTED"],
|
||||
READY_FOR_PREPARE: ["PREPARED", "ABORTED"],
|
||||
PREPARED: ["EXECUTING", "ABORTED"],
|
||||
EXECUTING: ["PARTIALLY_EXECUTED", "VALIDATING", "ABORTED"],
|
||||
PARTIALLY_EXECUTED: ["VALIDATING", "ABORTED"],
|
||||
VALIDATING: ["COMMITTED", "ABORTED"],
|
||||
COMMITTED: ["CLOSED"],
|
||||
ABORTED: ["UNWIND_PENDING", "CLOSED"],
|
||||
UNWIND_PENDING: ["CLOSED"],
|
||||
CLOSED: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: TransactionState, to: TransactionState): boolean {
|
||||
return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actor roles allowed to execute a transition. The coordinator may always
|
||||
* drive any transition programmatically; approver / releaser roles are
|
||||
* constrained for segregation-of-duties purposes (architecture note §13).
|
||||
*/
|
||||
export type ActorRole =
|
||||
| "coordinator"
|
||||
| "approver"
|
||||
| "releaser"
|
||||
| "validator"
|
||||
| "exception_manager"
|
||||
| "operator";
|
||||
|
||||
/**
|
||||
* Transitions that require a non-coordinator human actor (segregation of duties).
|
||||
* Per architecture note §13: "segregation of duties for approval and release
|
||||
* actions".
|
||||
*/
|
||||
export const SOD_REQUIRED_TRANSITIONS: ReadonlySet<`${TransactionState}->${TransactionState}`> = new Set([
|
||||
"READY_FOR_PREPARE->PREPARED", // release approval
|
||||
"PREPARED->EXECUTING", // release action
|
||||
"VALIDATING->COMMITTED", // final commit approval
|
||||
"ABORTED->UNWIND_PENDING", // unwind authorization
|
||||
]);
|
||||
|
||||
/**
|
||||
* Role required for each segregation-of-duties checkpoint.
|
||||
*/
|
||||
export const ROLE_FOR_TRANSITION: Readonly<Record<string, ActorRole>> = {
|
||||
"READY_FOR_PREPARE->PREPARED": "approver",
|
||||
"PREPARED->EXECUTING": "releaser",
|
||||
"VALIDATING->COMMITTED": "approver",
|
||||
"ABORTED->UNWIND_PENDING": "exception_manager",
|
||||
};
|
||||
82
orchestrator/tests/unit/planValidation.instrument.test.ts
Normal file
82
orchestrator/tests/unit/planValidation.instrument.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import { validatePlan } from "../../src/services/planValidation";
|
||||
import type { InstrumentTerms, Plan } from "../../src/types/plan";
|
||||
|
||||
const goodTerms: InstrumentTerms = {
|
||||
applicant: "Solace Bank Group PLC",
|
||||
issuingBankBIC: "SOLBAE22",
|
||||
beneficiaryBankBIC: "MEBLAEAD", // Emirates Islamic BIC prefix example
|
||||
beneficiaryName: "Acme Trading LLC",
|
||||
beneficiaryAccount: "AE070331234567890123456",
|
||||
amount: 1_000_000,
|
||||
currency: "USD",
|
||||
tenor: "90D",
|
||||
expiryDate: "2026-06-30",
|
||||
placeOfPresentation: "Dubai, UAE",
|
||||
governingLaw: "URDG 758",
|
||||
templateRef: "EIB-SBLC-v3.2",
|
||||
templateHash:
|
||||
"a".repeat(64), // dummy sha256
|
||||
};
|
||||
|
||||
function planWith(terms: Partial<InstrumentTerms> | null): Plan {
|
||||
return {
|
||||
creator: "solace-ops-01",
|
||||
steps: [
|
||||
{
|
||||
type: "issueInstrument",
|
||||
amount: terms?.amount ?? 1_000_000,
|
||||
instrument: terms === null ? undefined : ({ ...goodTerms, ...terms } as InstrumentTerms),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validatePlan — issueInstrument step", () => {
|
||||
it("accepts a well-formed SBLC step", () => {
|
||||
const result = validatePlan(planWith({}));
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a step missing the instrument object", () => {
|
||||
const result = validatePlan(planWith(null));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toMatch(/missing instrument terms/);
|
||||
});
|
||||
|
||||
it("rejects an invalid BIC", () => {
|
||||
const result = validatePlan(planWith({ issuingBankBIC: "NOTABIC" }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join("\n")).toMatch(/issuingBankBIC is not a valid BIC/);
|
||||
});
|
||||
|
||||
it("rejects a non-ISO-4217 currency", () => {
|
||||
const result = validatePlan(planWith({ currency: "usd" }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join("\n")).toMatch(/currency must be ISO 4217/);
|
||||
});
|
||||
|
||||
it("rejects a non-ISO-8601 expiry date", () => {
|
||||
const result = validatePlan(planWith({ expiryDate: "30-06-2026" }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join("\n")).toMatch(/expiryDate must be YYYY-MM-DD/);
|
||||
});
|
||||
|
||||
it("rejects a non-sha256 template hash", () => {
|
||||
const result = validatePlan(planWith({ templateHash: "deadbeef" }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join("\n")).toMatch(/templateHash must be 64 hex chars/);
|
||||
});
|
||||
|
||||
it("rejects an instrument with non-positive amount", () => {
|
||||
const result = validatePlan(planWith({ amount: 0 }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join("\n")).toMatch(/instrument.amount must be > 0/);
|
||||
});
|
||||
|
||||
it("accepts 11-char branched BIC", () => {
|
||||
const result = validatePlan(planWith({ issuingBankBIC: "SOLBAE22XXX" }));
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
85
orchestrator/tests/unit/transactionState.test.ts
Normal file
85
orchestrator/tests/unit/transactionState.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
ALLOWED_TRANSITIONS,
|
||||
ROLE_FOR_TRANSITION,
|
||||
SOD_REQUIRED_TRANSITIONS,
|
||||
TRANSACTION_STATES,
|
||||
canTransition,
|
||||
} from "../../src/types/transactionState";
|
||||
|
||||
describe("Transaction state machine (architecture note §8–§9)", () => {
|
||||
it("declares the 12 states from §8.1", () => {
|
||||
expect(TRANSACTION_STATES).toEqual([
|
||||
"DRAFT",
|
||||
"INITIATED",
|
||||
"PRECONDITIONS_PENDING",
|
||||
"READY_FOR_PREPARE",
|
||||
"PREPARED",
|
||||
"EXECUTING",
|
||||
"PARTIALLY_EXECUTED",
|
||||
"VALIDATING",
|
||||
"COMMITTED",
|
||||
"ABORTED",
|
||||
"UNWIND_PENDING",
|
||||
"CLOSED",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("§9.1 permitted high-level transitions", () => {
|
||||
// Each of these is listed in the note; canTransition must accept them.
|
||||
const legal: Array<[string, string]> = [
|
||||
["DRAFT", "INITIATED"],
|
||||
["INITIATED", "PRECONDITIONS_PENDING"],
|
||||
["PRECONDITIONS_PENDING", "READY_FOR_PREPARE"],
|
||||
["READY_FOR_PREPARE", "PREPARED"],
|
||||
["PREPARED", "EXECUTING"],
|
||||
["EXECUTING", "PARTIALLY_EXECUTED"],
|
||||
["EXECUTING", "VALIDATING"],
|
||||
["PARTIALLY_EXECUTED", "VALIDATING"],
|
||||
["VALIDATING", "COMMITTED"],
|
||||
["VALIDATING", "ABORTED"],
|
||||
["ABORTED", "UNWIND_PENDING"],
|
||||
["COMMITTED", "CLOSED"],
|
||||
["UNWIND_PENDING", "CLOSED"],
|
||||
];
|
||||
it.each(legal)("allows %s -> %s", (from, to) => {
|
||||
expect(canTransition(from as any, to as any)).toBe(true);
|
||||
});
|
||||
|
||||
// A few illegal edges — explicitly not in §9.1.
|
||||
const illegal: Array<[string, string]> = [
|
||||
["DRAFT", "COMMITTED"],
|
||||
["INITIATED", "EXECUTING"],
|
||||
["CLOSED", "INITIATED"],
|
||||
["PREPARED", "COMMITTED"],
|
||||
["COMMITTED", "ABORTED"],
|
||||
["ABORTED", "COMMITTED"],
|
||||
];
|
||||
it.each(illegal)("rejects %s -> %s", (from, to) => {
|
||||
expect(canTransition(from as any, to as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("CLOSED is a terminal state", () => {
|
||||
expect(ALLOWED_TRANSITIONS.CLOSED).toEqual([]);
|
||||
});
|
||||
|
||||
describe("segregation-of-duties checkpoints (§13)", () => {
|
||||
it("flags the four SoD-gated transitions", () => {
|
||||
expect([...SOD_REQUIRED_TRANSITIONS].sort()).toEqual(
|
||||
[
|
||||
"ABORTED->UNWIND_PENDING",
|
||||
"PREPARED->EXECUTING",
|
||||
"READY_FOR_PREPARE->PREPARED",
|
||||
"VALIDATING->COMMITTED",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("assigns a role to every SoD-gated transition", () => {
|
||||
for (const key of SOD_REQUIRED_TRANSITIONS) {
|
||||
expect(ROLE_FOR_TRANSITION[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user