diff --git a/core/invariants/mutations.ts b/core/invariants/mutations.ts index 11516df..54a8fab 100644 --- a/core/invariants/mutations.ts +++ b/core/invariants/mutations.ts @@ -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 = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly code: E }; +function err(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; @@ -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 { +): Result { + 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({ @@ -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', @@ -226,6 +239,27 @@ export function transferTicket( }); } + return ok(ticket); +} + +export function transferTicket( + state: DomainState, + params: TransferTicketParams, + riskContext: TransferRiskContext = {} +): Result { + 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, @@ -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); @@ -260,7 +294,7 @@ export function transferTicket( processedNonces: updatedNonces }; - return ok(nextState); + return ok(recordTransferAttempt(nextState, params, ticket.nonce, 'completed', assessment, context)); } /** @@ -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 { const ticket = state.tickets.get(params.ticketId); if (!ticket) { @@ -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 }); } - diff --git a/core/invariants/types.ts b/core/invariants/types.ts index 7a3ced9..dac184b 100644 --- a/core/invariants/types.ts +++ b/core/invariants/types.ts @@ -64,6 +64,8 @@ export interface DomainState { readonly transfers: readonly TransferRecord[]; readonly processedNonces: ReadonlySet; readonly blacklistedAddresses: ReadonlySet; + /** 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 = @@ -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'; diff --git a/core/transfer-risk/README.md b/core/transfer-risk/README.md new file mode 100644 index 0000000..7e9fcfe --- /dev/null +++ b/core/transfer-risk/README.md @@ -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. diff --git a/core/transfer-risk/__tests__/safe-acceptance.test.ts b/core/transfer-risk/__tests__/safe-acceptance.test.ts new file mode 100644 index 0000000..4ddef56 --- /dev/null +++ b/core/transfer-risk/__tests__/safe-acceptance.test.ts @@ -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"); +}); diff --git a/core/transfer-risk/__tests__/workflow.test.ts b/core/transfer-risk/__tests__/workflow.test.ts new file mode 100644 index 0000000..6182657 --- /dev/null +++ b/core/transfer-risk/__tests__/workflow.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStateCell, commitMutation } from "@/core/invariants/commit"; +import { transferTicket, type TransferTicketParams } from "@/core/invariants/mutations"; +import type { DomainState, Ticket } from "@/core/invariants/types"; +import { DEFAULT_TRANSFER_RISK_POLICY, assessTransferRisk } from "../policy"; +import { pendingTransferReviews, publicTransferStatus, requestTicketTransfer, reviewTicketTransfer } from "../workflow"; + +const NOW = 1_800_000_000_000; +const context = { now: NOW, deviceId: "device-one" }; +const organizer = { kind: "organizer" as const, id: "organizer" }; +const maintainer = { kind: "maintainer" as const, id: "maintainer" }; +function state(): DomainState { + const ticket: Ticket = { id: "ticket", eventId: "event", tierId: "general", ownerAddress: "alice", priceStroops: 100, + status: "issued", issuedAt: NOW - 86_400_000, validFrom: NOW + 3_600_000, validUntil: NOW + 7_200_000, fraudFlag: false, nonce: 0, metadataHash: "" }; + return { events: new Map([["event", { id: "event", organizerAddress: organizer.id, totalCapacity: 100, issuedCount: 1, status: "active", feeBasisPoints: 0 }]]), + tickets: new Map([[ticket.id, ticket]]), transfers: [], processedNonces: new Set(), blacklistedAddresses: new Set() }; +} +function request(overrides: Partial = {}): TransferTicketParams { + return { id: "transfer", ticketId: "ticket", fromAddress: "alice", toAddress: "bob", priceStroops: 100, + organizerFeeStroops: 5, protocolFeeStroops: 5, sellerProceedsStroops: 90, nonce: "nonce", ...overrides }; +} +function risky(): DomainState { + return { ...state(), transfers: [{ ...request({ id: "previous", nonce: "previous-nonce", fromAddress: "previous-owner", toAddress: "alice" }), timestamp: NOW - 1000 }] }; +} +function value(result: { ok: true; value: T } | { ok: false; code: unknown }): T { + if (!result.ok) throw new Error(JSON.stringify(result.code)); + return result.value; +} + +describe("server-side transfer risk workflow", () => { + it("completes a normal transfer atomically with history and nonce", () => { + const initial = state(), next = value(requestTicketTransfer(initial, request(), context)); + expect(next.tickets.get("ticket")?.ownerAddress).toBe("bob"); + expect(next.processedNonces.has("nonce")).toBe(true); + expect(next.transferRisk?.attempts[0].status).toBe("completed"); + expect(initial.tickets.get("ticket")?.ownerAddress).toBe("alice"); + }); + it("queues rapid resale before changing ownership or consuming the nonce", () => { + const next = value(requestTicketTransfer(risky(), request(), context)); + expect(next.tickets.get("ticket")?.ownerAddress).toBe("alice"); + expect(next.processedNonces.has("nonce")).toBe(false); + expect(next.transferRisk?.attempts[0]).toMatchObject({ status: "review_required", signals: ["rapid_resale"] }); + expect(pendingTransferReviews(next, organizer)).toHaveLength(1); + expect(pendingTransferReviews(next, { kind: "organizer", id: "other-organizer" })).toEqual([]); + }); + it("hard blocks combined rapid resale and suspicious recipient activity", () => { + const initial = risky(); + const transfers = Array.from({ length: 5 }, (_, index) => ({ ...request({ id: `other-${index}`, nonce: `other-${index}`, ticketId: `other-${index}` }), timestamp: NOW - 500 })); + const next = value(requestTicketTransfer({ ...initial, transfers: [...initial.transfers, ...transfers] }, request(), context)); + expect(publicTransferStatus(next, "transfer", "alice")?.code).toBe("transfer_blocked"); + expect(next.tickets.get("ticket")?.ownerAddress).toBe("alice"); + expect(reviewTicketTransfer(next, "transfer", "approve", maintainer, "verified", NOW + 1)).toEqual({ ok: false, code: "review_already_decided" }); + }); + it("audits an authorized approval and completes only that exact request", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + const approved = value(reviewTicketTransfer(queued, "transfer", "approve", organizer, "identity_verified", NOW + 1)); + expect(approved.transferRisk?.audit.at(-1)).toEqual({ transferId: "transfer", actor: organizer, reason: "identity_verified", timestamp: NOW + 1, status: "approved" }); + const next = value(requestTicketTransfer(approved, request(), { ...context, now: NOW + 2 })); + expect(next.tickets.get("ticket")?.ownerAddress).toBe("bob"); + expect(publicTransferStatus(next, "transfer", "alice")?.code).toBe("transfer_completed"); + expect(requestTicketTransfer(approved, request({ toAddress: "eve" }), context)).toEqual({ ok: false, code: "idempotency_conflict" }); + }); + it("retains rejection across repeated attempts and nonce rotation", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + const rejected = value(reviewTicketTransfer(queued, "transfer", "reject", maintainer, "recipient_unverified", NOW + 1)); + const replay = value(requestTicketTransfer(rejected, request(), context)); + expect(replay).toBe(rejected); + const rotated = value(requestTicketTransfer(rejected, request({ id: "retry", nonce: "new-nonce", toAddress: "eve" }), context)); + expect(publicTransferStatus(rotated, "retry", "alice")?.code).toBe("transfer_rejected"); + expect(rotated.tickets.get("ticket")?.ownerAddress).toBe("alice"); + }); + it("makes completed retries idempotent and rejects key collisions", () => { + const completed = value(requestTicketTransfer(state(), request(), context)); + expect(value(requestTicketTransfer(completed, request(), context))).toBe(completed); + expect(completed.transfers).toHaveLength(1); + expect(completed.transferRisk?.audit).toHaveLength(1); + expect(requestTicketTransfer(completed, request({ id: "other" }), context)).toEqual({ ok: false, code: "idempotency_conflict" }); + }); + it("does not allow a different ID to skip a pending review", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + const later = value(requestTicketTransfer(queued, request({ id: "new-id", nonce: "new-nonce" }), { ...context, now: NOW + 3_600_000 })); + expect(publicTransferStatus(later, "new-id", "alice")?.code).toBe("transfer_review_required"); + const direct = transferTicket(later, request({ id: "bypass", nonce: "bypass" }), { now: NOW + 3_600_000 }); + expect(!direct.ok && direct.code.code).toBe("transfer_review_required"); + }); + it("enforces review scope, attribution and valid reasons", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + expect(reviewTicketTransfer(queued, "transfer", "approve", { kind: "organizer", id: "other" }, "verified", NOW)).toEqual({ ok: false, code: "review_forbidden" }); + expect(reviewTicketTransfer(queued, "transfer", "approve", { kind: "maintainer", id: "alice" }, "verified", NOW)).toEqual({ ok: false, code: "review_forbidden" }); + expect(reviewTicketTransfer(queued, "transfer", "approve", organizer, "", NOW)).toEqual({ ok: false, code: "invalid_review_reason" }); + const approved = value(reviewTicketTransfer(queued, "transfer", "approve", organizer, "verified", NOW)); + expect(reviewTicketTransfer(approved, "transfer", "reject", organizer, "changed", NOW)).toEqual({ ok: false, code: "review_already_decided" }); + }); + it("cannot use an approval after ownership or lifecycle changes", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + const ticket = queued.tickets.get("ticket")!; + const stale = { ...queued, tickets: new Map([["ticket", { ...ticket, nonce: 1 }]]) }; + expect(reviewTicketTransfer(stale, "transfer", "approve", maintainer, "verified", NOW)).toEqual({ ok: false, code: "stale_review" }); + const approved = value(reviewTicketTransfer(queued, "transfer", "approve", organizer, "verified", NOW)); + const advanced = { ...approved, tickets: new Map([["ticket", { ...ticket, nonce: 2 }]]) }; + expect(requestTicketTransfer(advanced, request(), context)).toEqual({ ok: false, code: "stale_review" }); + const locked = { ...approved, tickets: new Map([["ticket", { ...ticket, status: "locked" as const }]]) }; + expect(requestTicketTransfer(locked, request(), context)).toEqual({ ok: false, code: "invalid_transfer" }); + }); + it("rechecks hard-blocking risk even after an approval", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + const approved = value(reviewTicketTransfer(queued, "transfer", "approve", organizer, "verified", NOW)); + const suspicious = Array.from({ length: 5 }, (_, index) => ({ ...request({ id: `new-${index}`, nonce: `new-${index}`, ticketId: `other-${index}` }), timestamp: NOW })); + const blocked = value(requestTicketTransfer({ ...approved, transfers: [...approved.transfers, ...suspicious] }, request(), context)); + expect(publicTransferStatus(blocked, "transfer", "alice")?.code).toBe("transfer_blocked"); + expect(blocked.tickets.get("ticket")?.ownerAddress).toBe("alice"); + }); + it("keeps fraud rules out of public explanations and scopes status reads", () => { + const queued = value(requestTicketTransfer(risky(), request(), context)); + const publicResult = publicTransferStatus(queued, "transfer", "alice"); + expect(Object.keys(publicResult!)).toEqual(["transferId", "status", "code", "message"]); + expect(JSON.stringify(publicResult)).not.toMatch(/rapid_resale|score|threshold|40000/); + expect(publicTransferStatus(queued, "transfer", "eve")).toBeNull(); + }); + it("uses server time rather than a caller's transfer timestamp", () => { + const initial = risky(); + const result = value(requestTicketTransfer(initial, request({ timestamp: NOW + 86_400_000 }), context)); + expect(result.transferRisk?.attempts[0].submittedAt).toBe(NOW); + expect(result.transferRisk?.attempts[0].status).toBe("review_required"); + }); + it("rejects malformed policy and negative fees before any ownership mutation", () => { + expect(requestTicketTransfer(state(), request(), { policy: { ...DEFAULT_TRANSFER_RISK_POLICY, reviewScore: -1 } })).toEqual({ ok: false, code: "invalid_risk_context" }); + expect(requestTicketTransfer(state(), request({ organizerFeeStroops: -5, sellerProceedsStroops: 100 }), context)).toEqual({ ok: false, code: "invalid_transfer" }); + }); + it("keeps a single committed transfer under racing identical retries", async () => { + const cell = createMemoryStateCell(state()); + const outcomes = await Promise.all(Array.from({ length: 5 }, () => commitMutation(cell, (snapshot) => requestTicketTransfer(snapshot, request(), context)))); + expect(outcomes.every((outcome) => outcome.ok)).toBe(true); + const committed = (await cell.read()).state; + expect(committed.transfers).toHaveLength(1); + expect(committed.transferRisk?.audit).toHaveLength(1); + }); + it("scores unusual timing and repeated device changes from server history", () => { + const initial = state(), template = initial.tickets.get("ticket")!; + const attempts = ["one", "two", "three", "four"].map((deviceId, index) => ({ id: `history-${index}`, request: request({ ticketId: `old-${index}`, id: `history-${index}`, nonce: `history-${index}` }), ticketVersion: 0, submittedAt: NOW - 1000 + index, status: "completed" as const, score: 0, signals: [], deviceId })); + const report = assessTransferRisk({ ...initial, tickets: new Map([["ticket", { ...template, validUntil: NOW + 1000 }]]), transferRisk: { attempts, audit: [] } }, request(), { ...context, deviceId: "four" }); + expect(report.signals).toEqual(["device_changes", "unusual_timing"]); + expect(report.disposition).toBe("review"); + const repeated = assessTransferRisk({ ...initial, transferRisk: { attempts, audit: [] } }, request(), { ...context, policy: { ...DEFAULT_TRANSFER_RISK_POLICY, maxAttempts: 3 } }); + expect(repeated.signals).toContain("repeated_attempts"); + }); +}); diff --git a/core/transfer-risk/policy.ts b/core/transfer-risk/policy.ts new file mode 100644 index 0000000..c789079 --- /dev/null +++ b/core/transfer-risk/policy.ts @@ -0,0 +1,71 @@ +import type { DomainState } from "@/core/invariants/types"; +import type { TransferTicketParams } from "@/core/invariants/mutations"; +import type { RiskSignal, TransferRiskAssessment, TransferRiskContext, TransferRiskPolicy } from "./types"; + +export const DEFAULT_TRANSFER_RISK_POLICY: TransferRiskPolicy = Object.freeze({ + rapidResaleMs: 5 * 60_000, + deviceWindowMs: 60 * 60_000, + maxDeviceChanges: 3, + recipientWindowMs: 60 * 60_000, + maxRecipientTransfers: 5, + lateTransferMs: 10 * 60_000, + retryWindowMs: 10 * 60_000, + maxAttempts: 5, + weights: Object.freeze({ rapid_resale: 40, device_changes: 30, recipient_pattern: 40, unusual_timing: 25, repeated_attempts: 40 }), + reviewScore: 40, + blockScore: 80 +}); + +export function validRiskContext(context: TransferRiskContext): boolean { + const policy = context.policy ?? DEFAULT_TRANSFER_RISK_POLICY; + const numbers = [policy.rapidResaleMs, policy.deviceWindowMs, policy.maxDeviceChanges, policy.recipientWindowMs, + policy.maxRecipientTransfers, policy.lateTransferMs, policy.retryWindowMs, policy.maxAttempts, + policy.reviewScore, policy.blockScore, ...Object.values(policy.weights ?? {})]; + return numbers.every((value) => Number.isSafeInteger(value) && value > 0) && + Object.values(policy.weights ?? {}).every((value) => value <= 1_000_000) && + Object.keys(DEFAULT_TRANSFER_RISK_POLICY.weights).every((key) => Object.hasOwn(policy.weights ?? {}, key)) && + policy.blockScore > policy.reviewScore && + (context.now === undefined || (Number.isSafeInteger(context.now) && context.now >= 0)) && + (context.deviceId === undefined || /^[a-zA-Z0-9:_-]{1,128}$/.test(context.deviceId)); +} + +/** Bind approval and idempotency to every value-bearing field, not just an ID. */ +export function sameTransfer(a: TransferTicketParams, b: TransferTicketParams): boolean { + return a.id === b.id && a.ticketId === b.ticketId && a.fromAddress === b.fromAddress && a.toAddress === b.toAddress && + a.nonce === b.nonce && a.priceStroops === b.priceStroops && a.organizerFeeStroops === b.organizerFeeStroops && + a.protocolFeeStroops === b.protocolFeeStroops && a.sellerProceedsStroops === b.sellerProceedsStroops; +} + +export function assessTransferRisk(state: DomainState, request: TransferTicketParams, context: TransferRiskContext = {}): TransferRiskAssessment { + if (!validRiskContext(context)) return { disposition: "block", score: 0, signals: [], code: "invalid_risk_context" }; + const now = context.now ?? Date.now(), policy = context.policy ?? DEFAULT_TRANSFER_RISK_POLICY; + const ticket = state.tickets.get(request.ticketId); + if (!ticket) return { disposition: "block", score: 0, signals: [], code: "transfer_blocked" }; + const attempts = state.transferRisk?.attempts ?? []; + const existing = attempts.find((attempt) => attempt.id === request.id || attempt.request.nonce === request.nonce); + if (existing && (!sameTransfer(existing.request, request) || existing.ticketVersion !== ticket.nonce)) { + return { disposition: "block", score: 0, signals: [], code: "transfer_blocked" }; + } + const fresh = (time: number, window: number) => time <= now && now - time <= window; + const ticketTransfers = state.transfers.filter((transfer) => transfer.ticketId === ticket.id); + const actorAttempts = attempts.filter((attempt) => attempt.request.fromAddress === request.fromAddress); + const devices = actorAttempts.filter((attempt) => fresh(attempt.submittedAt, policy.deviceWindowMs)).sort((left, right) => left.submittedAt - right.submittedAt).map((attempt) => attempt.deviceId).filter((device): device is string => device !== undefined); + if (context.deviceId) devices.push(context.deviceId); + let changes = 0; + for (let index = 1; index < devices.length; index++) if (devices[index] !== devices[index - 1]) changes++; + const signals: RiskSignal[] = []; + if (ticketTransfers.some((transfer) => fresh(transfer.timestamp, policy.rapidResaleMs))) signals.push("rapid_resale"); + if (changes >= policy.maxDeviceChanges) signals.push("device_changes"); + if (state.transfers.filter((transfer) => transfer.toAddress === request.toAddress && fresh(transfer.timestamp, policy.recipientWindowMs)).length >= policy.maxRecipientTransfers) signals.push("recipient_pattern"); + if (ticket.validUntil >= now && ticket.validUntil - now <= policy.lateTransferMs) signals.push("unusual_timing"); + if (actorAttempts.filter((attempt) => fresh(attempt.submittedAt, policy.retryWindowMs)).length >= policy.maxAttempts) signals.push("repeated_attempts"); + const score = signals.reduce((total, signal) => total + policy.weights[signal], 0); + // Decisions reserve a particular ownership version: changing nonce/recipient/ID cannot bypass review. + const active = attempts.filter((attempt) => attempt.request.ticketId === ticket.id && attempt.ticketVersion === ticket.nonce); + if (active.some((attempt) => attempt.status === "rejected")) return { disposition: "block", score, signals, code: "transfer_rejected" }; + if (active.some((attempt) => attempt.status === "blocked") || score >= policy.blockScore) return { disposition: "block", score, signals, code: "transfer_blocked" }; + const approved = active.find((attempt) => attempt.status === "approved" && sameTransfer(attempt.request, request)); + if (approved) return { disposition: "allow", score, signals, code: "transfer_approved" }; + if (active.some((attempt) => attempt.status === "review_required" || attempt.status === "approved") || score >= policy.reviewScore) return { disposition: "review", score, signals, code: "transfer_review_required" }; + return { disposition: "allow", score, signals, code: "transfer_completed" }; +} diff --git a/core/transfer-risk/records.ts b/core/transfer-risk/records.ts new file mode 100644 index 0000000..c3f0387 --- /dev/null +++ b/core/transfer-risk/records.ts @@ -0,0 +1,22 @@ +import type { DomainState } from "@/core/invariants/types"; +import type { TransferTicketParams } from "@/core/invariants/mutations"; +import type { TransferRiskAssessment, TransferRiskContext, TransferRiskStatus } from "./types"; + +/** Part of the same immutable state commit as ownership and nonce changes. */ +export function recordTransferAttempt(state: DomainState, request: TransferTicketParams, version: number, + status: TransferRiskStatus, assessment: TransferRiskAssessment, context: TransferRiskContext): DomainState { + const timestamp = context.now ?? Date.now(), previous = state.transferRisk ?? { attempts: [], audit: [] }; + const existing = previous.attempts.find((attempt) => attempt.id === request.id); + const attempt = { id: request.id, request: { ...request, timestamp }, ticketVersion: version, + submittedAt: existing?.submittedAt ?? timestamp, status, score: assessment.score, signals: [...assessment.signals], deviceId: existing?.deviceId ?? context.deviceId }; + const superseded = status === "completed" ? previous.attempts.filter((item) => item.id !== request.id && + item.request.ticketId === request.ticketId && item.ticketVersion === version && + (item.status === "review_required" || item.status === "approved")) : []; + return { ...state, transferRisk: { + attempts: [...previous.attempts.filter((item) => item.id !== request.id).map((item) => + superseded.includes(item) ? { ...item, status: "blocked" as const } : item), attempt], + audit: [...previous.audit, + ...superseded.map((item) => ({ transferId: item.id, actor: { kind: "system" as const, id: "transfer-risk" }, reason: "ownership_changed", timestamp, status: "blocked" as const })), + { transferId: request.id, actor: { kind: "user", id: request.fromAddress }, reason: assessment.code, timestamp, status }] + } }; +} diff --git a/core/transfer-risk/types.ts b/core/transfer-risk/types.ts new file mode 100644 index 0000000..9275251 --- /dev/null +++ b/core/transfer-risk/types.ts @@ -0,0 +1,65 @@ +import type { TransferTicketParams } from "@/core/invariants/mutations"; + +export type TransferRiskStatus = "review_required" | "blocked" | "approved" | "rejected" | "completed"; +export type TransferRiskCode = "transfer_review_required" | "transfer_blocked" | "transfer_rejected" | "transfer_completed" | "transfer_approved" | "invalid_risk_context"; +export type RiskSignal = "rapid_resale" | "device_changes" | "recipient_pattern" | "unusual_timing" | "repeated_attempts"; + +export interface TransferRiskPolicy { + readonly rapidResaleMs: number; + readonly deviceWindowMs: number; + readonly maxDeviceChanges: number; + readonly recipientWindowMs: number; + readonly maxRecipientTransfers: number; + readonly lateTransferMs: number; + readonly retryWindowMs: number; + readonly maxAttempts: number; + readonly weights: Readonly>; + readonly reviewScore: number; + readonly blockScore: number; +} + +/** Trusted server inputs. Never populate these from a request body's risk hints. */ +export interface TransferRiskContext { + readonly now?: number; + /** Opaque device fingerprint from the authenticated session, not a raw device identifier. */ + readonly deviceId?: string; + readonly policy?: TransferRiskPolicy; +} + +export interface TransferRiskAttempt { + readonly id: string; + readonly request: TransferTicketParams; + readonly ticketVersion: number; + readonly submittedAt: number; + readonly status: TransferRiskStatus; + readonly deviceId?: string; + /** Internal review data. Do not serialize this object to an attendee. */ + readonly score: number; + readonly signals: readonly RiskSignal[]; +} + +/** Actor must come from the application's authenticated, server-side principal. */ +export interface TransferReviewer { + readonly kind: "organizer" | "maintainer"; + readonly id: string; +} + +export interface TransferRiskAudit { + readonly transferId: string; + readonly actor: { readonly kind: "user" | "organizer" | "maintainer" | "system"; readonly id: string }; + readonly reason: string; + readonly timestamp: number; + readonly status: TransferRiskStatus; +} + +export interface TransferRiskState { + readonly attempts: readonly TransferRiskAttempt[]; + readonly audit: readonly TransferRiskAudit[]; +} + +export interface TransferRiskAssessment { + readonly disposition: "allow" | "review" | "block"; + readonly score: number; + readonly signals: readonly RiskSignal[]; + readonly code: TransferRiskCode; +} diff --git a/core/transfer-risk/workflow.ts b/core/transfer-risk/workflow.ts new file mode 100644 index 0000000..1f603f5 --- /dev/null +++ b/core/transfer-risk/workflow.ts @@ -0,0 +1,70 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { DomainState } from "@/core/invariants/types"; +import { transferTicket, validateTicketTransfer, type TransferTicketParams } from "@/core/invariants/mutations"; +import { assessTransferRisk, sameTransfer, validRiskContext } from "./policy"; +import { recordTransferAttempt } from "./records"; +import type { TransferReviewer, TransferRiskCode, TransferRiskContext, TransferRiskStatus } from "./types"; + +export type TransferWorkflowError = "idempotency_conflict" | "invalid_transfer" | "review_not_found" | "review_forbidden" | "invalid_review_reason" | "review_already_decided" | "stale_review" | "invalid_risk_context"; + +/** Run in commitMutation against server-owned state. Queued/blocked outcomes are committed, too. */ +export function requestTicketTransfer(state: DomainState, request: TransferTicketParams, context: TransferRiskContext = {}): Result { + if (!validRiskContext(context)) return err("invalid_risk_context"); + const existing = state.transferRisk?.attempts.find((attempt) => attempt.id === request.id || attempt.request.nonce === request.nonce); + if (existing && !sameTransfer(existing.request, request)) return err("idempotency_conflict"); + if (existing && existing.status !== "approved") return ok(state); + if (existing && state.tickets.get(request.ticketId)?.nonce !== existing.ticketVersion) return err("stale_review"); + const valid = validateTicketTransfer(state, request); + if (!valid.ok) return err("invalid_transfer"); + const assessment = assessTransferRisk(state, request, context); + if (assessment.code === "invalid_risk_context") return err("invalid_risk_context"); + if (assessment.disposition !== "allow") return ok(recordTransferAttempt(state, request, valid.value.nonce, + assessment.code === "transfer_rejected" ? "rejected" : assessment.disposition === "review" ? "review_required" : "blocked", assessment, context)); + const completed = transferTicket(state, request, context); + return completed.ok ? ok(completed.value) : err("invalid_transfer"); +} + +function mayReview(state: DomainState, ticketId: string, actor: TransferReviewer): boolean { + const ticket = state.tickets.get(ticketId), event = ticket && state.events.get(ticket.eventId); + return Boolean(event && actor.id.trim() && (actor.kind === "maintainer" || (actor.kind === "organizer" && actor.id === event.organizerAddress))); +} + +export function reviewTicketTransfer(state: DomainState, transferId: string, + decision: "approve" | "reject", actor: TransferReviewer, reason: string, now = Date.now()): Result { + const previous = state.transferRisk, attempt = previous?.attempts.find((item) => item.id === transferId); + if (!attempt || !previous) return err("review_not_found"); + if (!mayReview(state, attempt.request.ticketId, actor) || actor.id === attempt.request.fromAddress || actor.id === attempt.request.toAddress) return err("review_forbidden"); + if (!/^[a-z0-9_.:-]{1,64}$/.test(reason) || (decision !== "approve" && decision !== "reject")) return err("invalid_review_reason"); + if (!Number.isSafeInteger(now) || now < attempt.submittedAt) return err("invalid_risk_context"); + if (attempt.status !== "review_required") return err("review_already_decided"); + const ticket = state.tickets.get(attempt.request.ticketId); + if (!ticket || ticket.nonce !== attempt.ticketVersion || ticket.ownerAddress !== attempt.request.fromAddress || + (ticket.status !== "issued" && ticket.status !== "transferred") || ticket.fraudFlag) return err("stale_review"); + const status = decision === "approve" ? "approved" : "rejected"; + return ok({ ...state, transferRisk: { + attempts: previous.attempts.map((item) => item.id === transferId ? { ...item, status } : item), + audit: [...previous.audit, { transferId, actor: { ...actor }, reason, timestamp: now, status }] + } }); +} + +export function pendingTransferReviews(state: DomainState, actor: TransferReviewer) { + return (state.transferRisk?.attempts ?? []).filter((attempt) => attempt.status === "review_required" && + state.tickets.get(attempt.request.ticketId)?.nonce === attempt.ticketVersion && mayReview(state, attempt.request.ticketId, actor)); +} + +const PUBLIC_CODES: Record = { + review_required: "transfer_review_required", blocked: "transfer_blocked", approved: "transfer_approved", rejected: "transfer_rejected", completed: "transfer_completed" +}; +const PUBLIC_MESSAGES: Record = { + review_required: "This transfer needs organizer review before it can continue.", + blocked: "This transfer cannot continue. Contact the organizer for help.", + approved: "Review approved. Retry the same transfer request to complete it.", + rejected: "The transfer was declined during review. Contact the organizer for help.", + completed: "The transfer is complete." +}; + +/** Authorize ownership of this request before returning its public status to a user. */ +export function publicTransferStatus(state: DomainState, transferId: string, principalId: string) { + const attempt = state.transferRisk?.attempts.find((item) => item.id === transferId && item.request.fromAddress === principalId); + return attempt ? { transferId: attempt.id, status: attempt.status, code: PUBLIC_CODES[attempt.status], message: PUBLIC_MESSAGES[attempt.status] } : null; +} diff --git a/features/claimable-balances/lib/format.ts b/features/claimable-balances/lib/format.ts index 030da32..422ffe8 100644 --- a/features/claimable-balances/lib/format.ts +++ b/features/claimable-balances/lib/format.ts @@ -1,16 +1,3 @@ -export function formatAmount(value: string): string { - const [whole, fraction = ""] = value.split("."); - const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - const trimmed = fraction.replace(/0+$/, ""); - return trimmed ? `${grouped}.${trimmed}` : grouped; -} - -export function formatTimestamp(iso: string): string { - const date = new Date(iso); - return Number.isNaN(date.getTime()) - ? iso - : date.toISOString().replace("T", " ").replace(".000Z", " UTC"); -} import { formatAmount } from "@/core/format/amount"; import { formatDateTime } from "@/core/format/date"; import type { ClaimableBalanceSummary } from "@/features/claimable-balances/types"; diff --git a/features/duplicate-transaction-scanner/copy.ts b/features/duplicate-transaction-scanner/copy.ts index 5a73c56..e77cbac 100644 --- a/features/duplicate-transaction-scanner/copy.ts +++ b/features/duplicate-transaction-scanner/copy.ts @@ -19,7 +19,7 @@ export const copy = { // Empty state emptyHeading: "No scan yet", emptyBody: - "Enter one or more Stellar transaction hashes above and click "Scan for Duplicates".", + 'Enter one or more Stellar transaction hashes above and click "Scan for Duplicates".', // Loading loadingLabel: "Scanning transactions…", diff --git a/features/multisig-analyzer/README.md b/features/multisig-analyzer/README.md new file mode 100644 index 0000000..f916cc2 --- /dev/null +++ b/features/multisig-analyzer/README.md @@ -0,0 +1,43 @@ +# Multisig Signature Weight Analyzer — #125 + +At `/tools/multisig-analyzer`, paste a transaction envelope and the base G address +of its inner source. XDR decoding and cryptographic verification happen locally. +Only public source account addresses are requested from Horizon. The selected +network's passphrase determines the transaction hash. + +The analyzer loads signers and thresholds once per distinct source, with bounded +request concurrency. It checks the transaction source's low threshold, each +operation's source and low/medium/high threshold, extra-signer preconditions, and +a fee-bump source's separate low threshold. Muxed sources resolve to their base +accounts. `setOptions` needs high weight when any signer or threshold field is +present, including a zero-valued change; other options require medium weight. + +Ed25519 signatures are verified against the contents hash; matching four-byte +hints alone never count. Hash-preimage, preauthorized-transaction and signed-payload +keys are also verified. The signed-payload decoder reads the XDR payload length +explicitly because SDK 13's general signer decoder includes padding in the payload. +Each signer contributes once per check, and disabled signers contribute zero. +Even zero configured thresholds require a positive-weight authorization. + +Results show verified available weight, exact shortfalls, and the eligible missing +signers for every authorization check. Any sufficient combination can supply the +missing weight. Signature consumption follows the protocol's signer-type order +and early stopping so duplicate or surplus signatures are identified as possible +`tx_bad_auth_extra` failures. Inner and outer signatures are kept separate. + +This is envelope authorization analysis against current account snapshots. It +does not validate sequence numbers, operation effects, fees, balances, time bounds, +or Soroban contract authorization. Missing operation-source accounts produce a +typed lookup error, including accounts an earlier operation would create. Nothing +is signed or submitted. Secret keys are rejected and cleared; raw input is never +retained in the hook's error state. Network epoch changes discard stale work. + +Validation: `npx vitest run features/multisig-analyzer`, +`npm run verify:features -- multisig-analyzer`. The executable Playwright scenario +in `e2e/multisig-analyzer.spec.ts` covers request failure, recovery and responsive results. + +References: [signatures and multisig](https://developers.stellar.org/docs/learn/fundamentals/transactions/signatures-multisig), +[operation thresholds](https://developers.stellar.org/docs/learn/fundamentals/transactions/list-of-operations), +[Stellar Core signature consumption](https://github.com/stellar/stellar-core/blob/master/src/transactions/SignatureChecker.cpp). + +Browser verification: `npx playwright test --config=features/multisig-analyzer/playwright.config.ts` runs desktop and mobile checks in the real app. diff --git a/features/multisig-analyzer/__tests__/MultisigAnalyzerPanel.test.tsx b/features/multisig-analyzer/__tests__/MultisigAnalyzerPanel.test.tsx new file mode 100644 index 0000000..ee1c05f --- /dev/null +++ b/features/multisig-analyzer/__tests__/MultisigAnalyzerPanel.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import { renderFeatureSlice } from "@/core/testing/contract"; +import { withMswHandlers, http, HttpResponse, delay } from "@/core/testing/msw"; +import { MultisigAnalyzerPanel } from "../components/MultisigAnalyzerPanel"; +import { copy } from "../copy"; +import { handlers } from "../msw/handlers"; +import { secretSeed } from "../fixtures/multisigAnalyzer.fixture"; +import { fillForm } from "./helpers"; +const server = withMswHandlers(...handlers); + +describe("MultisigAnalyzer runtime contract", () => { + it("shows idle, loading and a usable network failure", async () => { + server.use(http.get("*", async () => { await delay(150); return HttpResponse.json({}, { status: 503 }); })); + const slice = renderFeatureSlice("multisig-analyzer", ); + slice.expectEmptyState(); + fillForm(); + await slice.user.click(screen.getByRole("button", { name: copy.submit })); + slice.expectLoadingState(); + await slice.waitForState("error"); + slice.expectErrorState(); + }); + it("renders a result through the real request boundary", async () => { + const slice = renderFeatureSlice("multisig-analyzer", ); + fillForm(); + await slice.user.click(screen.getByRole("button", { name: copy.submit })); + expect(await screen.findByRole("heading", { name: copy.resultTitle })).toBeInTheDocument(); + }); + it("clears pasted secrets without echoing them", () => { + const slice = renderFeatureSlice("multisig-analyzer", ); + fireEvent.change(screen.getByLabelText(copy.account), { target: { value: secretSeed } }); + expect(screen.getByLabelText(copy.account)).toHaveValue(""); + expect(slice.container.innerHTML).not.toContain(secretSeed); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/features/multisig-analyzer/__tests__/a11y.test.tsx b/features/multisig-analyzer/__tests__/a11y.test.tsx new file mode 100644 index 0000000..c7ef6d9 --- /dev/null +++ b/features/multisig-analyzer/__tests__/a11y.test.tsx @@ -0,0 +1,23 @@ +import { describe, it } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderFeature } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { withMswHandlers } from "@/core/testing/msw"; +import { MultisigAnalyzerPanel } from "../components/MultisigAnalyzerPanel"; +import { handlers } from "../msw/handlers"; +import { copy } from "../copy"; +import { fillForm } from "./helpers"; +withMswHandlers(...handlers); +describe("MultisigAnalyzer accessibility", () => { + it("passes in idle", async () => { + const { container } = renderFeature(); + await expectNoAxeViolations(container); + }); + it("passes with a real result", async () => { + const { container, user } = renderFeature(); + fillForm(); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByRole("heading", { name: copy.resultTitle }); + await expectNoAxeViolations(container); + }); +}); diff --git a/features/multisig-analyzer/__tests__/format.test.ts b/features/multisig-analyzer/__tests__/format.test.ts new file mode 100644 index 0000000..fd7d6fd --- /dev/null +++ b/features/multisig-analyzer/__tests__/format.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from "vitest"; +import { checkLabel } from "../lib/format"; +import { copy } from "../copy"; +it("identifies operation positions and source checks", () => { + const row = { kind: "operation" as const, operation: 2, operationType: "payment", accountId: "", level: "medium" as const, required: 1, present: 0, missing: 1, signers: [] }; + expect(checkLabel(row)).toBe(copy.operation(2, "payment")); + expect(checkLabel({ ...row, kind: "fee" })).toBe(copy.fee); +}); diff --git a/features/multisig-analyzer/__tests__/helpers.ts b/features/multisig-analyzer/__tests__/helpers.ts new file mode 100644 index 0000000..556e194 --- /dev/null +++ b/features/multisig-analyzer/__tests__/helpers.ts @@ -0,0 +1,5 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { copy } from "../copy"; +import { rawInput } from "../fixtures/multisigAnalyzer.fixture"; +export function fillForm() { fireEvent.change(screen.getByLabelText(copy.account), { target: { value: rawInput.accountId } }); + fireEvent.change(screen.getByLabelText(copy.envelope), { target: { value: rawInput.xdr } }); } diff --git a/features/multisig-analyzer/__tests__/multisigAnalyzer.test.ts b/features/multisig-analyzer/__tests__/multisigAnalyzer.test.ts new file mode 100644 index 0000000..1353cad --- /dev/null +++ b/features/multisig-analyzer/__tests__/multisigAnalyzer.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { Account, Asset, MuxedAccount, Networks, Operation, SignerKey, StrKey, TransactionBuilder, xdr, hash } from "@stellar/stellar-sdk"; +import { withMswHandlers, http, HttpResponse } from "@/core/testing/msw"; +import { runMultisigAnalyzer, operationThreshold } from "../lib/multisigAnalyzer"; +import { handlers } from "../msw/handlers"; +import { accountId, accountJson, master, second, other, stranger, makeTransaction } from "../fixtures/multisigAnalyzer.fixture"; +const server = withMswHandlers(...handlers); +const endpoint = `https://horizon-testnet.stellar.org/accounts/${accountId}`; +const run = (tx: { toXDR(): string }) => runMultisigAnalyzer({ accountId, xdr: tx.toXDR() }, "testnet"); +describe("cryptographic multisig analysis", () => { + it("reports the exact shortfall and remaining signer candidates", async () => { + const result = await run(makeTransaction()); + if (!result.ok) throw new Error(result.code); + expect(result.value.checks[1]).toMatchObject({ required: 3, present: 1, missing: 2 }); + expect(result.value.checks[1].signers.find((signer) => signer.key === second.publicKey())).toMatchObject({ present: false, weight: 2 }); + }); + it("satisfies the operation with two valid signers", async () => { + const result = await run(makeTransaction([master, second])); + expect(result.ok && result.value.authorizationSatisfied).toBe(true); + }); + it("requires every extra signer precondition", async () => { + const tx = new TransactionBuilder(new Account(accountId, "100"), { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.payment({ destination: other.publicKey(), asset: Asset.native(), amount: "1" })) + .setExtraSigners([stranger.publicKey()]).setTimeout(0).build(); + tx.sign(master, second); + const missing = await run(tx); + expect(missing.ok && missing.value.checks.find((row) => row.kind === "extra")).toMatchObject({ missing: 1 }); + tx.sign(stranger); + const complete = await run(tx); + expect(complete.ok && complete.value.authorizationSatisfied).toBe(true); + }); + it("resolves a muxed source to the correct base account", async () => { + const tx = new TransactionBuilder(new MuxedAccount(new Account(accountId, "100"), "42"), { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.payment({ destination: other.publicKey(), asset: Asset.native(), amount: "1" })) + .setTimeout(0).build(); + tx.sign(master, second); + const result = await run(tx); + expect(result.ok && result.value.authorizationSatisfied).toBe(true); + expect(result.ok && result.value.accountId).toBe(accountId); + }); + it("checks high and medium setOptions variants, including zero values", () => { + const medium = makeTransaction([], [Operation.setOptions({ homeDomain: "example.org" })]).operations[0]; + const high = makeTransaction([], [Operation.setOptions({ masterWeight: 0 })]).operations[0]; + expect(operationThreshold(medium)).toBe("medium"); expect(operationThreshold(high)).toBe("high"); + expect(operationThreshold(makeTransaction([], [Operation.inflation({})]).operations[0])).toBe("low"); + }); + it("does not count duplicate signatures twice", async () => { + const tx = makeTransaction(); tx.sign(master); + const result = await run(tx); + expect(result.ok && result.value.checks[1].present).toBe(1); + expect(result.ok && result.value.unusedInnerSignatures).toEqual([2]); + }); + it("does not trust a matching hint with an invalid signature", async () => { + const tx = makeTransaction([]); + tx.addDecoratedSignature(new xdr.DecoratedSignature({ hint: master.signatureHint(), signature: stranger.sign(tx.hash()) })); + const result = await run(tx); + expect(result.ok && result.value.checks[0].present).toBe(0); + }); + it("verifies against the selected network passphrase", async () => { + const result = await runMultisigAnalyzer({ accountId, xdr: makeTransaction([master, second]).toXDR() }, "mainnet"); + expect(result.ok && result.value.checks[1].present).toBe(0); + }); + it("requires a positive-weight signature even with zero thresholds", async () => { + server.use(http.get(endpoint, () => HttpResponse.json({ ...accountJson(), thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, signers: [{ key: accountId, weight: 0 }] }))); + const result = await run(makeTransaction()); + expect(result.ok && result.value.checks[0]).toMatchObject({ required: 1, present: 0, missing: 1 }); + }); + it("checks every operation source independently", async () => { + const tx = makeTransaction([master, second], [Operation.payment({ source: other.publicKey(), destination: accountId, asset: Asset.native(), amount: "1" })]); + const result = await run(tx); + expect(result.ok && result.value.checks[1]).toMatchObject({ accountId: other.publicKey(), present: 0, missing: 3 }); + }); + it("checks fee-bump signatures in a separate envelope domain", async () => { + const inner = makeTransaction([master, second]); + const outer = TransactionBuilder.buildFeeBumpTransaction(other.publicKey(), "200", inner, Networks.TESTNET); + const missing = await run(outer); + expect(missing.ok && missing.value.checks.at(-1)).toMatchObject({ kind: "fee", missing: 1 }); + outer.sign(other); + const present = await run(outer); + expect(present.ok && present.value.authorizationSatisfied).toBe(true); + }); + it("counts a preauthorized hash without an envelope signature", async () => { + const tx = makeTransaction([]); + server.use(http.get(endpoint, () => HttpResponse.json({ ...accountJson(), signers: [{ key: StrKey.encodePreAuthTx(tx.hash()), weight: 5 }] }))); + const result = await run(tx); + expect(result.ok && result.value.authorizationSatisfied).toBe(true); + }); + it("verifies hash-preimage signers", async () => { + const tx = makeTransaction([]), preimage = Buffer.from("deterministic-preimage"); tx.signHashX(preimage); + server.use(http.get(endpoint, () => HttpResponse.json({ ...accountJson(), signers: [{ key: StrKey.encodeSha256Hash(Buffer.from(hash(preimage))), weight: 5 }] }))); + const result = await run(tx); + expect(result.ok && result.value.authorizationSatisfied).toBe(true); + }); + it("verifies signed payloads and their decorated hints", async () => { + const tx = makeTransaction([]), payload = Buffer.from("payload"); + tx.addDecoratedSignature(master.signPayloadDecorated(payload)); + const key = SignerKey.encodeSignerKey(xdr.SignerKey.signerKeyTypeEd25519SignedPayload(new xdr.SignerKeyEd25519SignedPayload({ ed25519: master.rawPublicKey(), payload }))); + server.use(http.get(endpoint, () => HttpResponse.json({ ...accountJson(), signers: [{ key, weight: 5 }] }))); + const result = await run(tx); + expect(result.ok && result.value.authorizationSatisfied).toBe(true); + }); + it("rejects an address that does not match the inner source", async () => expect(await runMultisigAnalyzer({ accountId: other.publicKey(), xdr: makeTransaction().toXDR() }, "testnet")).toEqual({ ok: false, code: "source_mismatch" })); + it("maps missing accounts and invalid Horizon weights safely", async () => { + server.use(http.get(endpoint, () => HttpResponse.json({}, { status: 404 }))); + expect(await run(makeTransaction())).toEqual({ ok: false, code: "account_not_found" }); + server.use(http.get(endpoint, () => HttpResponse.json({ ...accountJson(), signers: [{ key: accountId, weight: -1 }] }))); + expect(await run(makeTransaction())).toEqual({ ok: false, code: "invalid_response" }); + }); +}); diff --git a/features/multisig-analyzer/__tests__/schema.test.ts b/features/multisig-analyzer/__tests__/schema.test.ts new file mode 100644 index 0000000..a491fbe --- /dev/null +++ b/features/multisig-analyzer/__tests__/schema.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { parseMultisigAnalyzerInput } from "../schema"; +import { rawInput, secretSeed } from "../fixtures/multisigAnalyzer.fixture"; +describe("multisig input", () => { + it("accepts an envelope with surrounding whitespace", () => expect(parseMultisigAnalyzerInput({ ...rawInput, xdr: `\n${rawInput.xdr}\n` })).toEqual({ ok: true, value: rawInput })); + it("rejects a secret embedded in envelope text", () => expect(parseMultisigAnalyzerInput({ ...rawInput, xdr: `prefix${secretSeed}suffix` })).toEqual({ ok: false, code: "secret_key" })); + it("bounds XDR size before decoding", () => expect(parseMultisigAnalyzerInput({ ...rawInput, xdr: "A".repeat(200004) })).toEqual({ ok: false, code: "input_too_large" })); + it("rejects non-base64 input", () => expect(parseMultisigAnalyzerInput({ ...rawInput, xdr: "not an envelope!" })).toEqual({ ok: false, code: "invalid_xdr" })); +}); diff --git a/features/multisig-analyzer/__tests__/useMultisigAnalyzer.test.tsx b/features/multisig-analyzer/__tests__/useMultisigAnalyzer.test.tsx new file mode 100644 index 0000000..731f058 --- /dev/null +++ b/features/multisig-analyzer/__tests__/useMultisigAnalyzer.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { NetworkProvider, useNetwork } from "@/core/network/NetworkProvider"; +import { createIsolatedStorage } from "@/core/testing/render"; +import { withMswHandlers, http, HttpResponse, delay } from "@/core/testing/msw"; +import { useMultisigAnalyzer } from "../hooks/useMultisigAnalyzer"; +import { handlers } from "../msw/handlers"; +import { rawInput, secretSeed } from "../fixtures/multisigAnalyzer.fixture"; +const server = withMswHandlers(...handlers); +function wrapper({ children }: { children: ReactNode }) { return {children}; } +describe("useMultisigAnalyzer", () => { + it("does not hold rejected secret input", async () => { + const { result } = renderHook(useMultisigAnalyzer, { wrapper }); + await act(async () => { await result.current.submit({ ...rawInput, accountId: secretSeed }); }); + expect(result.current.state).toEqual({ status: "error", code: "secret_key" }); + expect(JSON.stringify(result.current.state)).not.toContain(secretSeed); + }); + it("ignores in-flight work after reset or invalid submission", async () => { + server.use(http.get("*", async () => { await delay(100); return HttpResponse.json({}, { status: 503 }); })); + const { result } = renderHook(useMultisigAnalyzer, { wrapper }); + let pending: Promise; + act(() => { pending = result.current.submit(rawInput); }); + act(() => result.current.reset()); + await act(async () => { await pending; }); + expect(result.current.state.status).toBe("idle"); + act(() => { pending = result.current.submit(rawInput); }); + await act(async () => { await result.current.submit({ ...rawInput, accountId: "invalid" }); await pending; }); + expect(result.current.state).toEqual({ status: "error", code: "invalid_address" }); + }); + it("hides old results across a network round trip", async () => { + const { result } = renderHook(() => ({ tool: useMultisigAnalyzer(), network: useNetwork() }), { wrapper }); + await act(async () => { await result.current.tool.submit(rawInput); }); + await waitFor(() => expect(result.current.tool.state.status).toBe("success")); + act(() => result.current.network.setNetwork("mainnet")); + expect(result.current.tool.state.status).toBe("idle"); + act(() => result.current.network.setNetwork("testnet")); + expect(result.current.tool.state.status).toBe("idle"); + }); +}); diff --git a/features/multisig-analyzer/components/MultisigAnalyzerEmptyState.tsx b/features/multisig-analyzer/components/MultisigAnalyzerEmptyState.tsx new file mode 100644 index 0000000..3b3d5ef --- /dev/null +++ b/features/multisig-analyzer/components/MultisigAnalyzerEmptyState.tsx @@ -0,0 +1,7 @@ +import { ShieldCheck } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "../copy"; + +export function MultisigAnalyzerEmptyState() { + return ; +} diff --git a/features/multisig-analyzer/components/MultisigAnalyzerForm.tsx b/features/multisig-analyzer/components/MultisigAnalyzerForm.tsx new file mode 100644 index 0000000..da722d3 --- /dev/null +++ b/features/multisig-analyzer/components/MultisigAnalyzerForm.tsx @@ -0,0 +1,28 @@ +"use client"; +import { useState, type FormEvent } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { Input, Textarea } from "@/core/ui/Input"; +import type { MultisigAnalyzerRaw } from "../types"; +import { containsSecret } from "../schema"; +import { copy, errorCopy } from "../copy"; + +export function MultisigAnalyzerForm({ onSubmit, pending }: { onSubmit: (raw: MultisigAnalyzerRaw) => void; pending: boolean }) { + const [values, setValues] = useState({ accountId: "", xdr: "" }); + const [secrets, setSecrets] = useState({ accountId: false, xdr: false }); + function update(key: "accountId" | "xdr", value: string) { + const secret = containsSecret(value); + setSecrets((old) => ({ ...old, [key]: secret })); + setValues((old) => ({ ...old, [key]: secret ? "" : value })); + } + function submit(event: FormEvent) { event.preventDefault(); if (!secrets.accountId && !secrets.xdr) onSubmit(values); } + return
+ + {({ inputId, describedBy, invalid }) => update("accountId", event.target.value)} autoComplete="off" spellCheck={false} className="font-mono" />} + + + {({ inputId, describedBy, invalid }) =>