Skip to content
Merged
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
168 changes: 65 additions & 103 deletions core/invariants/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@
* redemptions, fraud locking, and burning that automatically uphold all domain invariants.
*/

import { err, ok, type Result } from '@/core/result/result';
import { DomainInvariantChecker } from './checker.ts';
import type { DomainState, Event, InvariantViolation, Ticket, TransferRecord } from './types.ts';
import { ok } from '@/core/result/result';
import { defaultOwnershipStore } from '@/core/ownership/ownership';
import { assessTransferRisk } from '@/core/transfer-risk/policy';
import { recordTransferAttempt } from '@/core/transfer-risk/records';
import type { TransferRiskContext } from '@/core/transfer-risk/types';

// Invariant failures carry a structured violation rather than a feature's string error code.
type Result<T, E> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly code: E };
function err<E>(code: E): { readonly ok: false; readonly code: E } { return { ok: false, code }; }
import { DomainInvariantChecker } from './checker';
import type { DomainState, InvariantViolation, Ticket, TransferRecord } from './types';

export interface IssueTicketParams {
readonly id: string;
Expand Down Expand Up @@ -143,10 +151,15 @@ export function issueTicket(
/**
* Transfers ticket ownership while verifying value conservation and replay protection.
*/
export function transferTicket(
export function validateTicketTransfer(
state: DomainState,
params: TransferTicketParams
): Result<DomainState, InvariantViolation> {
): Result<Ticket, InvariantViolation> {
if (!params.id.trim() || params.id.length > 128 || !params.nonce.trim() || params.nonce.length > 128 || !params.fromAddress.trim() || !params.toAddress.trim() || params.fromAddress === params.toAddress ||
[params.priceStroops, params.organizerFeeStroops, params.protocolFeeStroops, params.sellerProceedsStroops].some((amount) => !Number.isSafeInteger(amount) || amount < 0)) {
return err({ invariantId: 'INV-07', name: 'Stroop Precision & Positive Bounds', severity: 'critical',
entityId: params.id, entityType: 'transfer', message: 'Use a unique transfer ID and nonce, a different recipient, and non-negative safe integer fee amounts.' });
}
const ticket = state.tickets.get(params.ticketId);
if (!ticket) {
return err({
Expand Down Expand Up @@ -215,7 +228,7 @@ export function transferTicket(
}

// Verify nonce replay (INV-09)
if (state.processedNonces.has(params.nonce)) {
if (state.processedNonces.has(params.nonce) || state.transfers.some((transfer) => transfer.id === params.id)) {
return err({
invariantId: 'INV-09',
name: 'Nonce & Idempotency Replay Protection',
Expand All @@ -226,6 +239,27 @@ export function transferTicket(
});
}

return ok(ticket);
}

export function transferTicket(
state: DomainState,
params: TransferTicketParams,
riskContext: TransferRiskContext = {}
): Result<DomainState, InvariantViolation> {
const validation = validateTicketTransfer(state, params);
if (!validation.ok) return validation;
const ticket = validation.value;
const context = { ...riskContext, now: riskContext.now ?? Date.now() };
const assessment = assessTransferRisk(state, params, context);
if (assessment.disposition !== 'allow') return err({
invariantId: 'INV-05', name: 'Transfer risk review', severity: 'high',
entityId: params.id, entityType: 'transfer', code: assessment.code,
message: assessment.disposition === 'review'
? 'This transfer needs organizer review before it can continue.'
: 'This transfer cannot continue. Contact the organizer for help.'
});

// Apply transfer atomically
const updatedTicket: Ticket = {
...ticket,
Expand All @@ -244,7 +278,7 @@ export function transferTicket(
protocolFeeStroops: params.protocolFeeStroops,
sellerProceedsStroops: params.sellerProceedsStroops,
nonce: params.nonce,
timestamp: params.timestamp ?? Date.now()
timestamp: context.now
};

const updatedTickets = new Map(state.tickets);
Expand All @@ -260,7 +294,7 @@ export function transferTicket(
processedNonces: updatedNonces
};

return ok(nextState);
return ok(recordTransferAttempt(nextState, params, ticket.nonce, 'completed', assessment, context));
}

/**
Expand Down Expand Up @@ -355,7 +389,7 @@ export function initiateSafeTicketTransfer(
readonly correlationId?: string;
readonly actor: { kind: "user" | "maintainer" | "service" | "contract"; id: string };
},
store: import("@/core/ownership/ownership").OwnershipTransferStore = import("@/core/ownership/ownership").defaultOwnershipStore
store: import("@/core/ownership/ownership").OwnershipTransferStore = defaultOwnershipStore
): Result<import("@/core/ownership/types").OwnershipTransferProposal, InvariantViolation> {
const ticket = state.tickets.get(params.ticketId);
if (!ticket) {
Expand Down Expand Up @@ -445,101 +479,29 @@ export function acceptSafeTicketTransfer(
readonly actor: { kind: "user" | "maintainer" | "service" | "contract"; id: string };
readonly now?: number | Date;
},
store: import("@/core/ownership/ownership").OwnershipTransferStore = import("@/core/ownership/ownership").defaultOwnershipStore
store: import("@/core/ownership/ownership").OwnershipTransferStore = defaultOwnershipStore,
riskContext: TransferRiskContext = {}
): Result<{ state: DomainState; proposal: import("@/core/ownership/types").OwnershipTransferProposal }, InvariantViolation> {
const proposal = store.getTransfer(params.transferId, params.now);
if (!proposal) {
return err({
invariantId: "INV-02",
name: "Single Active Ownership",
severity: "critical",
entityId: params.transferId,
entityType: "transfer",
message: `Ownership transfer proposal '${params.transferId}' not found`
});
if (!proposal || proposal.resourceType !== 'ticket' || proposal.status !== 'pending' ||
(params.actor.kind !== 'maintainer' && params.actor.id.replace(/^account:/, '') !== proposal.proposedOwner.replace(/^account:/, ''))) {
return err({ invariantId: 'INV-02', name: 'Single Active Ownership', severity: 'high',
entityId: params.transferId, entityType: 'transfer', message: 'The pending transfer is unavailable or this actor cannot accept it.' });
}

const acceptResult = store.accept({
transferId: params.transferId,
actor: params.actor,
now: params.now
});

if (!acceptResult.ok) {
return err({
invariantId: "INV-02",
name: "Single Active Ownership",
severity: "high",
entityId: params.transferId,
entityType: "transfer",
message: `Ownership transfer acceptance rejected: ${acceptResult.code}`
});
}

const accepted = acceptResult.value;
const ticket = state.tickets.get(accepted.resourceId);
if (!ticket) {
return err({
invariantId: "INV-02",
name: "Single Active Ownership",
severity: "critical",
entityId: accepted.resourceId,
entityType: "ticket",
message: `Ticket '${accepted.resourceId}' not found for transfer`
});
}

// Check nonce replay
if (state.processedNonces.has(params.nonce)) {
return err({
invariantId: "INV-09",
name: "Nonce & Idempotency Replay Protection",
severity: "critical",
entityId: params.transferRecordId,
entityType: "transfer",
message: `Replay detected: transfer nonce '${params.nonce}' already processed`
});
}

// Apply transfer atomically
const updatedTicket: Ticket = {
...ticket,
ownerAddress: accepted.proposedOwner,
status: "transferred",
nonce: ticket.nonce + 1
};

const priceStroops = accepted.priceStroops ?? 0;
const organizerFee = params.organizerFeeStroops ?? 0;
const protocolFee = params.protocolFeeStroops ?? 0;
const sellerProceeds = params.sellerProceedsStroops ?? (priceStroops - organizerFee - protocolFee);

const transferRecord: TransferRecord = {
id: params.transferRecordId,
ticketId: ticket.id,
fromAddress: accepted.currentOwner,
toAddress: accepted.proposedOwner,
priceStroops,
organizerFeeStroops: organizerFee,
protocolFeeStroops: protocolFee,
sellerProceedsStroops: sellerProceeds,
nonce: params.nonce,
timestamp: typeof params.now === "number" ? params.now : Date.now()
};

const updatedTickets = new Map(state.tickets);
updatedTickets.set(updatedTicket.id, updatedTicket);

const updatedNonces = new Set(state.processedNonces);
updatedNonces.add(params.nonce);

const nextState: DomainState = {
...state,
tickets: updatedTickets,
transfers: [...state.transfers, transferRecord],
processedNonces: updatedNonces
};

return ok({ state: nextState, proposal: accepted });
const price = proposal.priceStroops ?? 0;
const organizerFee = params.organizerFeeStroops ?? 0, protocolFee = params.protocolFeeStroops ?? 0;
const now = params.now instanceof Date ? params.now.getTime() : params.now;
// Validate ownership, lifecycle, replay, fee conservation and risk before settling the proposal.
const transferred = transferTicket(state, {
id: params.transferRecordId, ticketId: proposal.resourceId,
fromAddress: proposal.currentOwner, toAddress: proposal.proposedOwner,
priceStroops: price, organizerFeeStroops: organizerFee, protocolFeeStroops: protocolFee,
sellerProceedsStroops: params.sellerProceedsStroops ?? price - organizerFee - protocolFee,
nonce: params.nonce
}, { ...riskContext, now: riskContext.now ?? now });
if (!transferred.ok) return transferred;
const accepted = store.accept({ transferId: params.transferId, actor: params.actor, now: params.now });
if (!accepted.ok) return err({ invariantId: 'INV-02', name: 'Single Active Ownership', severity: 'high',
entityId: params.transferId, entityType: 'transfer', message: 'The transfer proposal could not be accepted.' });
return ok({ state: transferred.value, proposal: accepted.value });
}

3 changes: 3 additions & 0 deletions core/invariants/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface DomainState {
readonly transfers: readonly TransferRecord[];
readonly processedNonces: ReadonlySet<string>;
readonly blacklistedAddresses: ReadonlySet<string>;
/** Persist with the ownership mutation; never expose this internal state in an attendee response. */
readonly transferRisk?: import("@/core/transfer-risk/types").TransferRiskState;
}

export type InvariantId =
Expand All @@ -80,6 +82,7 @@ export type InvariantId =
| 'INV-11'; // Blacklisted Principal Action Quarantine

export interface InvariantViolation {
readonly code?: import("@/core/transfer-risk/types").TransferRiskCode;
readonly invariantId: InvariantId;
readonly name: string;
readonly severity: 'critical' | 'high' | 'medium';
Expand Down
62 changes: 62 additions & 0 deletions core/transfer-risk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Ticket transfer risk and review — #173

This server-side domain workflow evaluates transfer risk before ownership changes.
It uses completed transfer history and committed attempts for rapid resale,
device changes, recipient concentration, late transfers and repeated attempts.
The policy in `policy.ts` is configurable through trusted server configuration.
Scores, signal names, fingerprints and thresholds are internal review data.

Use `requestTicketTransfer` inside `commitMutation`. A successful result can be
`completed`, `review_required`, `blocked` or `rejected`; **commit the returned state
for every successful result**, including queued and blocked outcomes. Read the
coarse outcome with `publicTransferStatus(state, id, authenticatedPrincipalId)`.
Only `completed` changes ownership and consumes the nonce. The public projection
uses stable codes and generic recovery text without exposing the rules.

```ts
const outcome = await commitMutation(cell, (state) =>
requestTicketTransfer(state, transfer, {
now: serverClock(),
deviceId: authenticatedSession.deviceFingerprint,
policy: serverRiskPolicy
})
);
```

The principal, device fingerprint, clock and policy must come from authenticated
server context. Do not accept them from a request body or put domain state in
an attendee's response. There is no new public mutation route: this repository's
existing transfer implementation is a domain library, and the read-only Stellar
tool UI remains read-only. The sandbox demo is synthetic and independent.

`pendingTransferReviews` scopes the queue to the event's organizer or a maintainer.
`reviewTicketTransfer` accepts an authenticated organizer/maintainer, approve/reject,
and a nonempty machine-readable reason (`identity_verified`, for example). It
appends actor, reason, timestamp and outcome to `transferRisk.audit` in the same
immutable state. Review state and audit must be stored with tickets, history and
nonces in the same database transaction/CAS; an external API should serialize only
the public projection. The in-memory `StateCell` is the repository's reference
adapter, not cross-process durable storage.

Approvals bind all transfer amounts, parties, ID and nonce to the ticket's ownership
version. Retry the exact approved request to complete it. Every domain invariant
is checked again, and a newly hard-blocking risk still prevents completion. Pending
and rejected reviews cannot be bypassed by rotating request IDs or nonces. Rejected
or hard-blocked ownership versions stay held; clearing such a hold requires a
separate authorized remediation workflow and is deliberately not automatic.
Completed retries return the existing state without extra transfers or audit rows.
Colliding IDs/nonces with different requests fail with `idempotency_conflict`.
Completing one reviewed request closes competing pending requests for that version.

The original `transferTicket` entry point also enforces the risk gate and records
successful attempts; risky direct calls return `transfer_review_required` or
`transfer_blocked` in the structured violation. Use `requestTicketTransfer` when
a queued outcome must be persisted. Safe ownership acceptance now calls the same
gate and validates fees, current ownership, locks and nonce before settling its
proposal. It refuses risky acceptance without marking the proposal accepted.

Validation: `npx vitest run core/transfer-risk core/ownership
core/invariants/__tests__/mutations.concurrency.test.ts`. Tests cover normal,
queued, approved, rejected and blocked transfers; scoped queue/status access;
review audit; forged timestamps; nonce rotation; payload conflicts; stale approvals;
and concurrent idempotent retries.
52 changes: 52 additions & 0 deletions core/transfer-risk/__tests__/safe-acceptance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, it } from "vitest";
import { Keypair } from "@stellar/stellar-sdk";
import { createOwnershipStore } from "@/core/ownership/ownership";
import { acceptSafeTicketTransfer } from "@/core/invariants/mutations";
import type { DomainState, Ticket } from "@/core/invariants/types";

const owner = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 71)).publicKey();
const recipient = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 72)).publicKey();
const NOW = 1_800_000_000_000;

function setup() {
const ticket: Ticket = { id: "ticket", eventId: "event", tierId: "general", ownerAddress: owner, priceStroops: 100,
status: "issued", issuedAt: NOW - 1000, validFrom: NOW, validUntil: NOW + 86_400_000, fraudFlag: false, nonce: 0, metadataHash: "" };
const state: DomainState = { tickets: new Map([[ticket.id, ticket]]),
events: new Map([["event", { id: "event", organizerAddress: "organizer", issuedCount: 1, totalCapacity: 10, status: "active", feeBasisPoints: 0 }]]),
transfers: [], processedNonces: new Set(), blacklistedAddresses: new Set() };
const store = createOwnershipStore();
const proposal = store.initiate({ resourceId: ticket.id, resourceType: "ticket", currentOwner: owner,
proposedOwner: recipient, priceStroops: 100, actor: { kind: "user", id: owner }, now: NOW });
if (!proposal.ok) throw new Error(proposal.code);
const params = { transferId: proposal.value.id, transferRecordId: "record", nonce: "nonce", actor: { kind: "user" as const, id: recipient }, now: NOW };
return { state, store, params };
}

it("refuses risky safe acceptance while leaving the proposal pending", () => {
const { state, store, params } = setup();
const previous = { id: "previous", ticketId: "ticket", fromAddress: "previous", toAddress: owner,
nonce: "previous", priceStroops: 100, organizerFeeStroops: 0, protocolFeeStroops: 0, sellerProceedsStroops: 100, timestamp: NOW - 1000 };
const result = acceptSafeTicketTransfer({ ...state, transfers: [previous] }, params, store);
expect(!result.ok && result.code.code).toBe("transfer_review_required");
expect(store.getTransfer(params.transferId, NOW)?.status).toBe("pending");
expect(state.tickets.get("ticket")?.ownerAddress).toBe(owner);
});

it("validates ownership and fee conservation before settling a proposal", () => {
const { state, store, params } = setup();
const invalidFee = acceptSafeTicketTransfer(state, { ...params, organizerFeeStroops: 120, sellerProceedsStroops: 0 }, store);
expect(invalidFee.ok).toBe(false);
expect(store.getTransfer(params.transferId, NOW)?.status).toBe("pending");
const ticket = state.tickets.get("ticket")!;
const stale = { ...state, tickets: new Map([[ticket.id, { ...ticket, ownerAddress: recipient }]]) };
expect(acceptSafeTicketTransfer(stale, params, store).ok).toBe(false);
expect(store.getTransfer(params.transferId, NOW)?.status).toBe("pending");
});

it("applies the same gate to a normal recipient acceptance", () => {
const { state, store, params } = setup();
const result = acceptSafeTicketTransfer(state, params, store);
expect(result.ok && result.value.proposal.status).toBe("accepted");
expect(result.ok && result.value.state.tickets.get("ticket")?.ownerAddress).toBe(recipient);
expect(result.ok && result.value.state.transferRisk?.attempts[0].status).toBe("completed");
});
Loading