From bdb2ad785b59d316ef9fbb8a37525f5f70828fba Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:42:17 +0100 Subject: [PATCH 1/5] feat(transaction): add spending policy engine with multi-approver controls Applications had no way to enforce spending policy before a transaction was signed or submitted. This adds SpendingPolicyEngine, which evaluates a request against configured limits and returns a structured decision. Supports per-transaction, daily and monthly limits configured per asset via setSpendingLimit(asset, amount, period), destination allow/deny restrictions, and approval thresholds requiring N distinct approvers. Concurrency: evaluate() reserves capacity at decision time rather than at submission time. Authorized and pending-approval records both count toward cumulative windows, so two concurrent requests cannot each independently fit under one ceiling. rejectRequest() and markFailed() release the reservation; markCompleted() retains it. Amounts are compared as scaled BigInts at stroop precision so values beyond the IEEE-754 safe integer range keep full precision. Daily and monthly windows are computed in UTC. Duplicate approvals from the same identity are rejected so a single approver cannot satisfy a multi-approver requirement alone. The engine records only decisions made through this SDK instance; it does not observe on-chain activity, so limits constrain the application rather than the account itself. Documented on the class. --- src/index.ts | 16 + src/tests/spendingPolicy.test.ts | 476 ++++++++++++++++++++++++++ src/transaction/index.ts | 17 + src/transaction/spendingPolicy.ts | 547 ++++++++++++++++++++++++++++++ 4 files changed, 1056 insertions(+) create mode 100644 src/tests/spendingPolicy.test.ts create mode 100644 src/transaction/spendingPolicy.ts diff --git a/src/index.ts b/src/index.ts index 2c0c548..1c1003e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -875,3 +875,19 @@ export type { CongestionLevel, CongestionSnapshot, } from "./network/congestionMonitor"; +export { SpendingPolicyEngine, createSpendingPolicyEngine } from "./transaction/spendingPolicy"; +export type { + SpendingLimitPeriod, + SpendingLimit, + DestinationRestriction, + ApprovalThreshold, + SpendingPolicyConfig, + SpendingRequest, + SpendingRecordStatus, + SpendingRecord, + PolicyViolationCode, + PolicyViolation, + SpendingDecision, + SpendingEvaluation, + SpendingUsage, +} from "./transaction/spendingPolicy"; diff --git a/src/tests/spendingPolicy.test.ts b/src/tests/spendingPolicy.test.ts new file mode 100644 index 0000000..fb42218 --- /dev/null +++ b/src/tests/spendingPolicy.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from "vitest"; +import { + createSpendingPolicyEngine, + SpendingPolicyEngine, +} from "../transaction/spendingPolicy"; +import type { SpendingPolicyConfig } from "../transaction/spendingPolicy"; + +const NATIVE = "native"; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const DEST_A = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; +const DEST_B = "GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3"; + +function engineWith(config?: SpendingPolicyConfig): SpendingPolicyEngine { + return createSpendingPolicyEngine(config); +} + +// A fixed instant so daily/monthly window maths stay deterministic. +const T = Date.UTC(2026, 4, 15, 12, 0, 0); +const DAY = 24 * 60 * 60 * 1000; + +describe("setSpendingLimit", () => { + it("stores a limit and returns the normalized amount", () => { + const engine = engineWith(); + const result = engine.setSpendingLimit(NATIVE, "100.5000000", "daily"); + + expect(result.status).toBe("ok"); + expect(result.data).toEqual({ asset: NATIVE, amount: "100.5", period: "daily" }); + expect(engine.listSpendingLimits()).toHaveLength(1); + }); + + it("rejects a malformed amount", () => { + const engine = engineWith(); + const result = engine.setSpendingLimit(NATIVE, "not-a-number", "daily"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an amount with more precision than stroops allow", () => { + const engine = engineWith(); + expect(engine.setSpendingLimit(NATIVE, "1.123456789", "daily").status).toBe("error"); + }); + + it("rejects an empty asset", () => { + expect(engineWith().setSpendingLimit(" ", "10", "daily").status).toBe("error"); + }); + + it("replaces the ceiling when the same asset and period are reconfigured", () => { + const engine = engineWith(); + engine.setSpendingLimit(NATIVE, "100", "daily"); + engine.setSpendingLimit(NATIVE, "250", "daily"); + + expect(engine.listSpendingLimits()).toEqual([ + { asset: NATIVE, amount: "250", period: "daily" }, + ]); + }); + + it("removes a configured limit", () => { + const engine = engineWith(); + engine.setSpendingLimit(NATIVE, "100", "daily"); + + expect(engine.removeSpendingLimit(NATIVE, "daily")).toBe(true); + expect(engine.removeSpendingLimit(NATIVE, "daily")).toBe(false); + expect(engine.listSpendingLimits()).toHaveLength(0); + }); +}); + +describe("per-transaction limits", () => { + it("allows a transaction at exactly the limit", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "per_transaction" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + + expect(result.data?.decision).toBe("allowed"); + expect(result.data?.violations).toEqual([]); + }); + + it("denies a transaction above the limit with a structured violation", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "per_transaction" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "100.0000001", timestamp: T }); + + expect(result.data?.decision).toBe("denied"); + expect(result.data?.violations[0]).toMatchObject({ + code: "PER_TRANSACTION_LIMIT_EXCEEDED", + asset: NATIVE, + limit: "100", + requested: "100.0000001", + }); + }); + + it("does not accumulate across separate transactions", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "per_transaction" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T }); + + expect(second.data?.decision).toBe("allowed"); + }); +}); + +describe("cumulative daily and monthly limits", () => { + it("denies the request that would push the day over its ceiling", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "60", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "50", timestamp: T }); + + expect(second.data?.decision).toBe("denied"); + expect(second.data?.violations[0]).toMatchObject({ + code: "DAILY_LIMIT_EXCEEDED", + used: "60", + requested: "50", + limit: "100", + }); + }); + + it("resets the daily window on the next UTC day", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const nextDay = engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T + DAY }); + + expect(nextDay.data?.decision).toBe("allowed"); + }); + + it("keeps the monthly window accruing across days", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "150", period: "monthly" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const nextDay = engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T + DAY }); + + expect(nextDay.data?.decision).toBe("denied"); + expect(nextDay.data?.violations[0]?.code).toBe("MONTHLY_LIMIT_EXCEEDED"); + }); + + it("resets the monthly window in the following month", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "150", period: "monthly" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const nextMonth = engine.evaluate({ + id: "r2", + asset: NATIVE, + amount: "150", + timestamp: Date.UTC(2026, 5, 1, 0, 0, 0), + }); + + expect(nextMonth.data?.decision).toBe("allowed"); + }); + + it("reports every breached window at once", () => { + const engine = engineWith({ + limits: [ + { asset: NATIVE, amount: "10", period: "per_transaction" }, + { asset: NATIVE, amount: "10", period: "daily" }, + { asset: NATIVE, amount: "10", period: "monthly" }, + ], + }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "50", timestamp: T }); + + expect(result.data?.violations.map((v) => v.code)).toEqual([ + "PER_TRANSACTION_LIMIT_EXCEEDED", + "DAILY_LIMIT_EXCEEDED", + "MONTHLY_LIMIT_EXCEEDED", + ]); + }); +}); + +describe("per-asset limit isolation", () => { + it("does not let one asset consume another asset's ceiling", () => { + const engine = engineWith({ + limits: [ + { asset: NATIVE, amount: "100", period: "daily" }, + { asset: USDC, amount: "100", period: "daily" }, + ], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const usdc = engine.evaluate({ id: "r2", asset: USDC, amount: "100", timestamp: T }); + + expect(usdc.data?.decision).toBe("allowed"); + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("100"); + expect(engine.getSpendingUsage(USDC, T).daily).toBe("100"); + }); + + it("leaves an unconfigured asset unconstrained", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "1", period: "daily" }] }); + const result = engine.evaluate({ id: "r1", asset: USDC, amount: "999999", timestamp: T }); + + expect(result.data?.decision).toBe("allowed"); + }); +}); + +describe("concurrent requests", () => { + it("counts an authorized-but-unsubmitted request against the ceiling", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + + // Neither request has been submitted yet — the second must still see the first. + const first = engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }); + + expect(first.data?.decision).toBe("allowed"); + expect(second.data?.decision).toBe("denied"); + }); + + it("counts a pending-approval request against the ceiling", () => { + const engine = engineWith({ + limits: [{ asset: NATIVE, amount: "100", period: "daily" }], + approvalThresholds: [{ asset: NATIVE, amount: "10" }], + }); + const first = engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }); + + expect(first.data?.decision).toBe("requires_approval"); + expect(second.data?.decision).toBe("denied"); + }); + + it("releases reserved capacity when a request is rejected", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + engine.rejectRequest("r1"); + + const retry = engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }); + expect(retry.data?.decision).toBe("allowed"); + }); + + it("releases reserved capacity when a request fails", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + engine.markFailed("r1"); + + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("0"); + expect(engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }).data?.decision).toBe( + "allowed", + ); + }); + + it("keeps capacity consumed once a request completes", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + engine.markCompleted("r1"); + + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("70"); + expect(engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }).data?.decision).toBe( + "denied", + ); + }); + + it("rejects a duplicate request id rather than double-reserving", () => { + const engine = engineWith(); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + const duplicate = engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + + expect(duplicate.status).toBe("error"); + expect(duplicate.error?.message).toContain("already been evaluated"); + }); + + it("does not reserve capacity for a denied request", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "10", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "50", timestamp: T }); + + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("0"); + }); +}); + +describe("destination restrictions", () => { + it("denies a destination outside the allow list", () => { + const engine = engineWith({ + destinationRestriction: { mode: "allow", destinations: [DEST_A] }, + }); + const result = engine.evaluate({ + id: "r1", + asset: NATIVE, + amount: "1", + destination: DEST_B, + timestamp: T, + }); + + expect(result.data?.decision).toBe("denied"); + expect(result.data?.violations[0]?.code).toBe("DESTINATION_NOT_ALLOWED"); + }); + + it("permits a destination inside the allow list", () => { + const engine = engineWith({ + destinationRestriction: { mode: "allow", destinations: [DEST_A] }, + }); + const result = engine.evaluate({ + id: "r1", + asset: NATIVE, + amount: "1", + destination: DEST_A, + timestamp: T, + }); + + expect(result.data?.decision).toBe("allowed"); + }); + + it("denies a destination on the deny list and permits others", () => { + const engine = engineWith({ + destinationRestriction: { mode: "deny", destinations: [DEST_A] }, + }); + + expect( + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", destination: DEST_A, timestamp: T }).data + ?.violations[0]?.code, + ).toBe("DESTINATION_DENIED"); + expect( + engine.evaluate({ id: "r2", asset: NATIVE, amount: "1", destination: DEST_B, timestamp: T }).data + ?.decision, + ).toBe("allowed"); + }); + + it("clears a restriction when set to undefined", () => { + const engine = engineWith({ + destinationRestriction: { mode: "allow", destinations: [DEST_A] }, + }); + engine.setDestinationRestriction(undefined); + + expect( + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", destination: DEST_B, timestamp: T }).data + ?.decision, + ).toBe("allowed"); + }); +}); + +describe("approval workflow", () => { + it("routes a transaction above the threshold into an approval state", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + + expect(result.data?.decision).toBe("requires_approval"); + expect(result.data?.requiredApprovers).toBe(1); + expect(engine.getRequest("r1")?.status).toBe("pending_approval"); + }); + + it("leaves a transaction at or below the threshold immediately allowed", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + + expect(result.data?.decision).toBe("allowed"); + expect(result.data?.requiredApprovers).toBeUndefined(); + }); + + it("authorizes once a single approval arrives", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const approved = engine.approveRequest("r1", "alice"); + + expect(approved.data?.status).toBe("authorized"); + expect(approved.data?.approvals).toEqual(["alice"]); + }); + + it("holds a multi-approver request until every approval is collected", () => { + const engine = engineWith({ + approvalThresholds: [{ asset: NATIVE, amount: "100", requiredApprovers: 3 }], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + + expect(engine.approveRequest("r1", "alice").data?.status).toBe("pending_approval"); + expect(engine.approveRequest("r1", "bob").data?.status).toBe("pending_approval"); + expect(engine.approveRequest("r1", "carol").data?.status).toBe("authorized"); + expect(engine.getRequest("r1")?.approvals).toEqual(["alice", "bob", "carol"]); + }); + + it("rejects a duplicate approval so one approver cannot satisfy the threshold alone", () => { + const engine = engineWith({ + approvalThresholds: [{ asset: NATIVE, amount: "100", requiredApprovers: 2 }], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + engine.approveRequest("r1", "alice"); + const duplicate = engine.approveRequest("r1", "alice"); + + expect(duplicate.status).toBe("error"); + expect(duplicate.error?.message).toContain("already approved"); + expect(engine.getRequest("r1")?.status).toBe("pending_approval"); + }); + + it("rejects an approver outside the configured approver set", () => { + const engine = engineWith({ + approvalThresholds: [{ asset: NATIVE, amount: "100", approvers: ["alice"] }], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const result = engine.approveRequest("r1", "mallory"); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not an authorized approver"); + }); + + it("rejects an approval for an unknown request", () => { + expect(engineWith().approveRequest("nope", "alice").status).toBe("error"); + }); + + it("rejects an approval for a request that is not pending", () => { + const engine = engineWith(); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + const result = engine.approveRequest("r1", "alice"); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not pending approval"); + }); + + it("rejects a configured threshold with fewer than one approver", () => { + const engine = engineWith(); + expect(engine.setApprovalThreshold({ asset: NATIVE, amount: "10", requiredApprovers: 0 }).status).toBe( + "error", + ); + }); + + it("can reject a pending request outright", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + + expect(engine.rejectRequest("r1").data?.status).toBe("rejected"); + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("0"); + }); + + it("refuses to complete a request still awaiting approval", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const result = engine.markCompleted("r1"); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("only authorized requests can complete"); + }); +}); + +describe("usage reporting and lifecycle", () => { + it("reports the largest single spend as per-transaction usage", () => { + const engine = engineWith(); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "10", timestamp: T }); + engine.evaluate({ id: "r2", asset: NATIVE, amount: "45", timestamp: T }); + + const usage = engine.getSpendingUsage(NATIVE, T); + expect(usage.perTransaction).toBe("45"); + expect(usage.daily).toBe("55"); + expect(usage.monthly).toBe("55"); + }); + + it("filters listed requests by status", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + engine.evaluate({ id: "r2", asset: NATIVE, amount: "150", timestamp: T }); + + expect(engine.listRequests("authorized").map((r) => r.id)).toEqual(["r1"]); + expect(engine.listRequests("pending_approval").map((r) => r.id)).toEqual(["r2"]); + expect(engine.listRequests()).toHaveLength(2); + }); + + it("clears records but retains limits on reset", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + engine.reset(); + + expect(engine.listRequests()).toHaveLength(0); + expect(engine.listSpendingLimits()).toHaveLength(1); + expect(engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T }).data?.decision).toBe( + "allowed", + ); + }); + + it("rejects a malformed request amount", () => { + const result = engineWith().evaluate({ id: "r1", asset: NATIVE, amount: "-5", timestamp: T }); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an empty request id", () => { + expect(engineWith().evaluate({ id: " ", asset: NATIVE, amount: "1" }).status).toBe("error"); + }); + + it("preserves precision on large amounts beyond the safe integer range", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "922337203685.4775807", period: "daily" }] }); + const result = engine.evaluate({ + id: "r1", + asset: NATIVE, + amount: "922337203685.4775807", + timestamp: T, + }); + + expect(result.data?.decision).toBe("allowed"); + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("922337203685.4775807"); + }); +}); diff --git a/src/transaction/index.ts b/src/transaction/index.ts index 374a6b8..f43b207 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -442,3 +442,20 @@ export async function compareFeeAcrossNetworks( // and therefore lives in src/soroban/simulateTransaction.ts. // It can be accessed via client.soroban.simulate(). + +export { SpendingPolicyEngine, createSpendingPolicyEngine } from "./spendingPolicy"; +export type { + SpendingLimitPeriod, + SpendingLimit, + DestinationRestriction, + ApprovalThreshold, + SpendingPolicyConfig, + SpendingRequest, + SpendingRecordStatus, + SpendingRecord, + PolicyViolationCode, + PolicyViolation, + SpendingDecision, + SpendingEvaluation, + SpendingUsage, +} from "./spendingPolicy"; diff --git a/src/transaction/spendingPolicy.ts b/src/transaction/spendingPolicy.ts new file mode 100644 index 0000000..e604f2c --- /dev/null +++ b/src/transaction/spendingPolicy.ts @@ -0,0 +1,547 @@ +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +// ─── Types ─── + +/** Window over which a cumulative limit accrues. */ +export type SpendingLimitPeriod = "per_transaction" | "daily" | "monthly"; + +/** + * A single configured limit. + * + * `asset` is the canonical asset identifier ("native" or "CODE:ISSUER"). A limit + * configured for one asset never constrains spending of another. + */ +export interface SpendingLimit { + asset: string; + amount: string; + period: SpendingLimitPeriod; +} + +/** + * A destination restriction applied to every evaluated transaction. + * + * When `mode` is "allow", only the listed destinations may receive funds. When + * "deny", the listed destinations are rejected and all others are permitted. + */ +export interface DestinationRestriction { + mode: "allow" | "deny"; + destinations: readonly string[]; +} + +/** + * Threshold above which a transaction requires explicit approval rather than + * being rejected outright. + */ +export interface ApprovalThreshold { + asset: string; + amount: string; + /** Number of distinct approvers that must approve. Defaults to 1. */ + requiredApprovers?: number; + /** Approvers permitted to act. When omitted, any approver identity is accepted. */ + approvers?: readonly string[]; +} + +export interface SpendingPolicyConfig { + limits?: readonly SpendingLimit[]; + destinationRestriction?: DestinationRestriction; + approvalThresholds?: readonly ApprovalThreshold[]; +} + +/** A transaction presented to the engine for evaluation. */ +export interface SpendingRequest { + id: string; + asset: string; + amount: string; + destination?: string; + /** Epoch milliseconds the request was made. Defaults to `Date.now()`. */ + timestamp?: number; +} + +/** + * Lifecycle of an evaluated request. + * + * Only `authorized`, `pending_approval` and `completed` records consume limit + * capacity — see {@link SpendingPolicyEngine.getSpendingUsage}. `rejected` and + * `failed` records release the capacity they had reserved. + */ +export type SpendingRecordStatus = + | "authorized" + | "pending_approval" + | "completed" + | "failed" + | "rejected"; + +export interface SpendingRecord { + id: string; + asset: string; + amount: string; + destination?: string; + timestamp: number; + status: SpendingRecordStatus; + approvals: readonly string[]; + requiredApprovers: number; +} + +export type PolicyViolationCode = + | "PER_TRANSACTION_LIMIT_EXCEEDED" + | "DAILY_LIMIT_EXCEEDED" + | "MONTHLY_LIMIT_EXCEEDED" + | "DESTINATION_NOT_ALLOWED" + | "DESTINATION_DENIED"; + +/** Structured description of a single failed policy rule. */ +export interface PolicyViolation { + code: PolicyViolationCode; + asset: string; + /** Configured ceiling for the rule that failed. Absent for destination rules. */ + limit?: string; + /** Spend already consuming the window at evaluation time. */ + used?: string; + /** Amount the request asked for. */ + requested?: string; + message: string; +} + +export type SpendingDecision = "allowed" | "requires_approval" | "denied"; + +export interface SpendingEvaluation { + decision: SpendingDecision; + requestId: string; + violations: readonly PolicyViolation[]; + /** Present when `decision` is "requires_approval". */ + requiredApprovers?: number; +} + +export interface SpendingUsage { + asset: string; + perTransaction: string; + daily: string; + monthly: string; +} + +// ─── Amount helpers ─── +// +// Amounts are decimal strings. They are compared as scaled BigInts so that +// values beyond IEEE-754 safe range keep full precision. + +const SCALE = 7; +const SCALE_FACTOR = 10n ** BigInt(SCALE); +const AMOUNT_PATTERN = /^\d+(\.\d+)?$/; + +function parseAmount(value: string): bigint | undefined { + const trimmed = value.trim(); + if (!AMOUNT_PATTERN.test(trimmed)) return undefined; + const [whole = "0", fraction = ""] = trimmed.split("."); + if (fraction.length > SCALE) return undefined; + return BigInt(whole) * SCALE_FACTOR + BigInt(fraction.padEnd(SCALE, "0") || "0"); +} + +function formatAmount(value: bigint): string { + const whole = value / SCALE_FACTOR; + const fraction = (value % SCALE_FACTOR).toString().padStart(SCALE, "0").replace(/0+$/, ""); + return fraction ? `${whole}.${fraction}` : `${whole}`; +} + +// ─── Window helpers ─── + +function startOfUtcDay(timestamp: number): number { + const date = new Date(timestamp); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +function startOfUtcMonth(timestamp: number): number { + const date = new Date(timestamp); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); +} + +/** + * Records that consume limit capacity. + * + * Pending and authorized requests are counted so that concurrent evaluations + * cannot each independently fit under a shared ceiling. + */ +function consumesCapacity(status: SpendingRecordStatus): boolean { + return status === "authorized" || status === "pending_approval" || status === "completed"; +} + +// ─── Engine ─── + +/** + * Evaluates transactions against configured spending limits before signing or + * submission. + * + * The engine is an in-memory ledger of decisions this SDK instance has made. It + * does not observe on-chain activity: spending performed outside the engine is + * invisible to it, so limits constrain the application, not the account itself. + */ +export class SpendingPolicyEngine { + private readonly limits = new Map(); + private readonly approvalThresholds = new Map(); + private readonly records = new Map(); + private destinationRestriction: DestinationRestriction | undefined; + + constructor(config?: SpendingPolicyConfig) { + for (const limit of config?.limits ?? []) { + this.setSpendingLimit(limit.asset, limit.amount, limit.period); + } + for (const threshold of config?.approvalThresholds ?? []) { + this.setApprovalThreshold(threshold); + } + if (config?.destinationRestriction) { + this.destinationRestriction = config.destinationRestriction; + } + } + + /** + * Configure a limit for an asset over a period. + * + * Re-configuring the same (asset, period) pair replaces the previous ceiling. + * Historical records are retained, so lowering a limit can leave the current + * window already over capacity. + */ + setSpendingLimit( + asset: string, + amount: string, + period: SpendingLimitPeriod, + ): SorokitResult { + if (!asset.trim()) { + return err(SorokitErrorCode.INVALID_CONFIG, "setSpendingLimit: asset is required."); + } + const parsed = parseAmount(amount); + if (parsed === undefined) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `setSpendingLimit: amount "${amount}" is not a non-negative decimal with at most ${SCALE} places.`, + ); + } + const limit: SpendingLimit = { asset, amount: formatAmount(parsed), period }; + this.limits.set(`${asset}:${period}`, limit); + return ok(limit); + } + + /** Remove a previously configured limit. Returns true when one was removed. */ + removeSpendingLimit(asset: string, period: SpendingLimitPeriod): boolean { + return this.limits.delete(`${asset}:${period}`); + } + + /** List all configured limits. */ + listSpendingLimits(): SpendingLimit[] { + return [...this.limits.values()]; + } + + /** Restrict which destinations may receive funds. Pass `undefined` to clear. */ + setDestinationRestriction(restriction: DestinationRestriction | undefined): void { + this.destinationRestriction = restriction; + } + + /** + * Configure the amount above which an asset's transactions require approval. + */ + setApprovalThreshold(threshold: ApprovalThreshold): SorokitResult { + const parsed = parseAmount(threshold.amount); + if (parsed === undefined) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `setApprovalThreshold: amount "${threshold.amount}" is not a valid decimal amount.`, + ); + } + if (threshold.requiredApprovers !== undefined && threshold.requiredApprovers < 1) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "setApprovalThreshold: requiredApprovers must be >= 1.", + ); + } + this.approvalThresholds.set(threshold.asset, threshold); + return ok(threshold); + } + + /** + * Spend currently consuming each window for an asset. + * + * `perTransaction` reports the largest single capacity-consuming request, not + * a sum, because that limit applies to each transaction individually. + */ + getSpendingUsage(asset: string, now: number = Date.now()): SpendingUsage { + const dayStart = startOfUtcDay(now); + const monthStart = startOfUtcMonth(now); + let daily = 0n; + let monthly = 0n; + let largest = 0n; + + for (const record of this.records.values()) { + if (record.asset !== asset || !consumesCapacity(record.status)) continue; + const amount = parseAmount(record.amount) ?? 0n; + if (amount > largest) largest = amount; + if (record.timestamp >= monthStart) monthly += amount; + if (record.timestamp >= dayStart) daily += amount; + } + + return { + asset, + perTransaction: formatAmount(largest), + daily: formatAmount(daily), + monthly: formatAmount(monthly), + }; + } + + /** + * Evaluate a transaction against every configured rule. + * + * On an "allowed" or "requires_approval" decision the request is recorded and + * immediately reserves capacity, so a second concurrent evaluation sees the + * first one's spend. Release it with {@link SpendingPolicyEngine.rejectRequest} + * or {@link SpendingPolicyEngine.markFailed} if the transaction never reaches + * the network. + * + * @returns `ok(SpendingEvaluation)`, or an error when the request is malformed. + */ + evaluate(request: SpendingRequest): SorokitResult { + if (!request.id.trim()) { + return err(SorokitErrorCode.INVALID_CONFIG, "evaluate: request id is required."); + } + if (this.records.has(request.id)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `evaluate: request "${request.id}" has already been evaluated.`, + ); + } + const amount = parseAmount(request.amount); + if (amount === undefined) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `evaluate: amount "${request.amount}" is not a valid decimal amount.`, + ); + } + + const timestamp = request.timestamp ?? Date.now(); + const violations: PolicyViolation[] = []; + + const destinationViolation = this.checkDestination(request); + if (destinationViolation) violations.push(destinationViolation); + + violations.push(...this.checkLimits(request.asset, amount, timestamp)); + + if (violations.length > 0) { + this.records.set(request.id, { + id: request.id, + asset: request.asset, + amount: formatAmount(amount), + ...(request.destination !== undefined ? { destination: request.destination } : {}), + timestamp, + status: "rejected", + approvals: [], + requiredApprovers: 0, + }); + return ok({ decision: "denied", requestId: request.id, violations }); + } + + const requiredApprovers = this.requiredApproversFor(request.asset, amount); + const status: SpendingRecordStatus = requiredApprovers > 0 ? "pending_approval" : "authorized"; + + this.records.set(request.id, { + id: request.id, + asset: request.asset, + amount: formatAmount(amount), + ...(request.destination !== undefined ? { destination: request.destination } : {}), + timestamp, + status, + approvals: [], + requiredApprovers, + }); + + return ok({ + decision: requiredApprovers > 0 ? "requires_approval" : "allowed", + requestId: request.id, + violations: [], + ...(requiredApprovers > 0 ? { requiredApprovers } : {}), + }); + } + + private checkDestination(request: SpendingRequest): PolicyViolation | undefined { + const restriction = this.destinationRestriction; + if (!restriction || request.destination === undefined) return undefined; + const listed = restriction.destinations.includes(request.destination); + + if (restriction.mode === "allow" && !listed) { + return { + code: "DESTINATION_NOT_ALLOWED", + asset: request.asset, + message: `Destination ${request.destination} is not in the allow list.`, + }; + } + if (restriction.mode === "deny" && listed) { + return { + code: "DESTINATION_DENIED", + asset: request.asset, + message: `Destination ${request.destination} is explicitly denied.`, + }; + } + return undefined; + } + + private checkLimits(asset: string, amount: bigint, timestamp: number): PolicyViolation[] { + const violations: PolicyViolation[] = []; + const usage = this.getSpendingUsage(asset, timestamp); + + const perTransaction = this.limits.get(`${asset}:per_transaction`); + if (perTransaction) { + const ceiling = parseAmount(perTransaction.amount) ?? 0n; + if (amount > ceiling) { + violations.push({ + code: "PER_TRANSACTION_LIMIT_EXCEEDED", + asset, + limit: perTransaction.amount, + used: "0", + requested: formatAmount(amount), + message: `Transaction amount ${formatAmount(amount)} exceeds the per-transaction limit of ${perTransaction.amount}.`, + }); + } + } + + const daily = this.limits.get(`${asset}:daily`); + if (daily) { + const ceiling = parseAmount(daily.amount) ?? 0n; + const used = parseAmount(usage.daily) ?? 0n; + if (used + amount > ceiling) { + violations.push({ + code: "DAILY_LIMIT_EXCEEDED", + asset, + limit: daily.amount, + used: usage.daily, + requested: formatAmount(amount), + message: `Daily limit of ${daily.amount} exceeded — ${usage.daily} already authorized, ${formatAmount(amount)} requested.`, + }); + } + } + + const monthly = this.limits.get(`${asset}:monthly`); + if (monthly) { + const ceiling = parseAmount(monthly.amount) ?? 0n; + const used = parseAmount(usage.monthly) ?? 0n; + if (used + amount > ceiling) { + violations.push({ + code: "MONTHLY_LIMIT_EXCEEDED", + asset, + limit: monthly.amount, + used: usage.monthly, + requested: formatAmount(amount), + message: `Monthly limit of ${monthly.amount} exceeded — ${usage.monthly} already authorized, ${formatAmount(amount)} requested.`, + }); + } + } + + return violations; + } + + private requiredApproversFor(asset: string, amount: bigint): number { + const threshold = this.approvalThresholds.get(asset); + if (!threshold) return 0; + const ceiling = parseAmount(threshold.amount) ?? 0n; + if (amount <= ceiling) return 0; + return threshold.requiredApprovers ?? 1; + } + + /** + * Record an approval from `approver`. + * + * Duplicate approvals from the same identity are rejected so that one approver + * cannot satisfy a multi-approver requirement alone. The record becomes + * "authorized" once the required count is reached. + */ + approveRequest(requestId: string, approver: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `approveRequest: unknown request "${requestId}".`); + } + if (record.status !== "pending_approval") { + return err( + SorokitErrorCode.INVALID_CONFIG, + `approveRequest: request "${requestId}" is ${record.status}, not pending approval.`, + ); + } + if (record.approvals.includes(approver)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `approveRequest: ${approver} has already approved request "${requestId}".`, + ); + } + + const permitted = this.approvalThresholds.get(record.asset)?.approvers; + if (permitted && !permitted.includes(approver)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `approveRequest: ${approver} is not an authorized approver for ${record.asset}.`, + ); + } + + const approvals = [...record.approvals, approver]; + const updated: SpendingRecord = { + ...record, + approvals, + status: approvals.length >= record.requiredApprovers ? "authorized" : "pending_approval", + }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Reject a request, releasing the capacity it reserved. */ + rejectRequest(requestId: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `rejectRequest: unknown request "${requestId}".`); + } + const updated: SpendingRecord = { ...record, status: "rejected" }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Mark an authorized request as submitted and confirmed. Capacity stays consumed. */ + markCompleted(requestId: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `markCompleted: unknown request "${requestId}".`); + } + if (record.status !== "authorized") { + return err( + SorokitErrorCode.INVALID_CONFIG, + `markCompleted: request "${requestId}" is ${record.status} — only authorized requests can complete.`, + ); + } + const updated: SpendingRecord = { ...record, status: "completed" }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Mark a request as failed, releasing the capacity it reserved. */ + markFailed(requestId: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `markFailed: unknown request "${requestId}".`); + } + const updated: SpendingRecord = { ...record, status: "failed" }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Look up a single evaluated request. */ + getRequest(requestId: string): SpendingRecord | undefined { + return this.records.get(requestId); + } + + /** List evaluated requests, optionally filtered by status. */ + listRequests(status?: SpendingRecordStatus): SpendingRecord[] { + const all = [...this.records.values()]; + return status ? all.filter((record) => record.status === status) : all; + } + + /** Discard all recorded requests. Configured limits are retained. */ + reset(): void { + this.records.clear(); + } +} + +/** Construct a {@link SpendingPolicyEngine}. */ +export function createSpendingPolicyEngine(config?: SpendingPolicyConfig): SpendingPolicyEngine { + return new SpendingPolicyEngine(config); +} From 6e1f27f8064db95ff3816c60014e79cb45826619 Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:45:30 +0100 Subject: [PATCH 2/5] feat(soroban): add contract state snapshots, pinning and historical queries Reproducing a past contract state required capturing it by hand. This adds ContractStateHistory, a registry of SDK-managed snapshots keyed by contract and ledger sequence. Each snapshot carries contract id, ledger reference, capture timestamp and a deterministic state fingerprint. The fingerprint is an FNV-1a digest over a canonical JSON encoding that sorts object keys at every depth, so structurally equal states always fingerprint identically regardless of key insertion order. It detects drift and makes comparison cheap; it is not collision-resistant and is documented as unsuitable as a security boundary. pinContractState(contractId, version) pins a contract to a captured ledger. Pinning or querying a ledger that was never captured fails with an explicit CONTRACT_READ_FAILED rather than silently resolving the nearest version or returning empty state, so callers can distinguish "unavailable" from "no state". Scope is documented on the class: this stores what was captured through it and cannot reconstruct arbitrary historical ledger state, which lives outside RPC retention windows. compareSnapshots reports added, removed and changed entries and rejects comparisons across different contracts. Captured state is copied and frozen so later mutation of the caller's object cannot invalidate a stored snapshot. Complements the existing label-keyed contractSnapshot.ts and stateSnapshots.ts, which capture live state over RPC and carry no version or integrity metadata. --- src/index.ts | 15 + src/soroban/contractStateHistory.ts | 366 ++++++++++++++++++++++++ src/soroban/index.ts | 16 ++ src/tests/contractStateHistory.test.ts | 377 +++++++++++++++++++++++++ 4 files changed, 774 insertions(+) create mode 100644 src/soroban/contractStateHistory.ts create mode 100644 src/tests/contractStateHistory.test.ts diff --git a/src/index.ts b/src/index.ts index 2c0c548..7d1c473 100644 --- a/src/index.ts +++ b/src/index.ts @@ -875,3 +875,18 @@ export type { CongestionLevel, CongestionSnapshot, } from "./network/congestionMonitor"; +export { + ContractStateHistory, + createContractStateHistory, + fingerprintState, +} from "./soroban/contractStateHistory"; +export type { + ContractStateSnapshotRecord, + CaptureSnapshotInput, + ContractStatePin, + StateEntryChangeKind, + StateEntryChange, + ContractStateComparison, + SnapshotIntegrityReport, + SnapshotQuery, +} from "./soroban/contractStateHistory"; diff --git a/src/soroban/contractStateHistory.ts b/src/soroban/contractStateHistory.ts new file mode 100644 index 0000000..27a66ad --- /dev/null +++ b/src/soroban/contractStateHistory.ts @@ -0,0 +1,366 @@ +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +// ─── Types ─── + +/** + * A recorded contract state snapshot. + * + * A snapshot is SDK-managed: it holds the state that was supplied to + * {@link ContractStateHistory.captureSnapshot} at the moment of capture. It is + * not a claim that this state can be reconstructed from the network later — see + * {@link ContractStateHistory} for the distinction. + */ +export interface ContractStateSnapshotRecord { + /** Stable identifier, unique within the history. */ + id: string; + contractId: string; + /** Ledger sequence the state was read at, used as the version identifier. */ + ledger: number; + /** Epoch milliseconds at which the snapshot was captured. */ + timestamp: number; + /** Captured state, stored as supplied. */ + state: Readonly>; + /** Deterministic fingerprint of `state` — see {@link fingerprintState}. */ + fingerprint: string; + /** Optional human-readable label. */ + label?: string; +} + +export interface CaptureSnapshotInput { + contractId: string; + ledger: number; + state: Record; + /** Epoch milliseconds. Defaults to `Date.now()`. */ + timestamp?: number; + label?: string; +} + +/** A pin marking one snapshot as the active version for a contract. */ +export interface ContractStatePin { + contractId: string; + snapshotId: string; + ledger: number; + pinnedAt: number; +} + +export type StateEntryChangeKind = "added" | "removed" | "changed"; + +export interface StateEntryChange { + key: string; + kind: StateEntryChangeKind; + /** Value in the earlier snapshot. Absent when the entry was added. */ + from?: unknown; + /** Value in the later snapshot. Absent when the entry was removed. */ + to?: unknown; +} + +export interface ContractStateComparison { + contractId: string; + fromSnapshotId: string; + toSnapshotId: string; + fromLedger: number; + toLedger: number; + /** True when both snapshots carry the same fingerprint. */ + identical: boolean; + changes: readonly StateEntryChange[]; +} + +export interface SnapshotIntegrityReport { + snapshotId: string; + valid: boolean; + expectedFingerprint: string; + actualFingerprint: string; +} + +export interface SnapshotQuery { + contractId?: string; + /** Only snapshots at or after this ledger. */ + fromLedger?: number; + /** Only snapshots at or before this ledger. */ + toLedger?: number; +} + +// ─── Fingerprinting ─── + +/** + * Canonical JSON encoding: object keys are emitted in sorted order at every + * depth so that two structurally equal states always encode identically, + * regardless of the insertion order of their keys. + */ +function canonicalize(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "bigint") return `"${value.toString()}"`; + if (typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`); + return `{${entries.join(",")}}`; +} + +/** + * Deterministic fingerprint of a contract state object. + * + * This is a non-cryptographic FNV-1a digest over the canonical encoding. It + * detects accidental drift and lets two snapshots be compared cheaply; it is + * not collision-resistant and must not be used as a security boundary. + */ +export function fingerprintState(state: Record): string { + const encoded = canonicalize(state); + let hash = 0x811c9dc5n; + const PRIME = 0x01000193n; + const MASK = 0xffffffffn; + + for (let index = 0; index < encoded.length; index += 1) { + hash = ((hash ^ BigInt(encoded.charCodeAt(index))) * PRIME) & MASK; + } + return hash.toString(16).padStart(8, "0"); +} + +// ─── History ─── + +/** + * Registry of SDK-managed contract state snapshots. + * + * Scope: this class stores state that was captured through it and can pin, + * query and compare those records. It cannot reconstruct arbitrary historical + * ledger state — Stellar RPC nodes retain contract data for a limited retention + * window, and any ledger outside that window (or never captured here) is simply + * unavailable. Queries for such state report a clear error rather than + * returning an empty or synthesized result. + */ +export class ContractStateHistory { + private readonly snapshots = new Map(); + private readonly pins = new Map(); + private sequence = 0; + + /** + * Record a snapshot of contract state at a given ledger. + * + * The state is fingerprinted at capture time. Capturing the same contract at + * the same ledger twice is allowed and produces two independent records. + */ + captureSnapshot(input: CaptureSnapshotInput): SorokitResult { + if (!input.contractId.trim()) { + return err(SorokitErrorCode.INVALID_CONFIG, "captureSnapshot: contractId is required."); + } + if (!Number.isInteger(input.ledger) || input.ledger < 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `captureSnapshot: ledger must be a non-negative integer (got ${input.ledger}).`, + ); + } + if (input.state === null || typeof input.state !== "object") { + return err(SorokitErrorCode.INVALID_CONFIG, "captureSnapshot: state must be an object."); + } + + this.sequence += 1; + const state = Object.freeze({ ...input.state }); + const record: ContractStateSnapshotRecord = { + id: `${input.contractId}:${input.ledger}:${this.sequence}`, + contractId: input.contractId, + ledger: input.ledger, + timestamp: input.timestamp ?? Date.now(), + state, + fingerprint: fingerprintState(state), + ...(input.label !== undefined ? { label: input.label } : {}), + }; + + this.snapshots.set(record.id, record); + return ok(record); + } + + /** + * Pin a contract to a specific captured version. + * + * `version` is a ledger sequence. The most recently captured snapshot at that + * ledger becomes the pinned state. Pinning a ledger that was never captured + * fails, rather than silently pinning the nearest one. + */ + pinContractState(contractId: string, version: number): SorokitResult { + const candidates = [...this.snapshots.values()] + .filter((snapshot) => snapshot.contractId === contractId && snapshot.ledger === version) + .sort((a, b) => a.timestamp - b.timestamp); + + const target = candidates[candidates.length - 1]; + if (!target) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `pinContractState: no snapshot captured for contract ${contractId} at ledger ${version}. ` + + "Historical state outside the captured set is not retrievable from this SDK — capture it first.", + ); + } + + const pin: ContractStatePin = { + contractId, + snapshotId: target.id, + ledger: target.ledger, + pinnedAt: Date.now(), + }; + this.pins.set(contractId, pin); + return ok(pin); + } + + /** Return the active pin for a contract, if one is set. */ + getPin(contractId: string): ContractStatePin | undefined { + return this.pins.get(contractId); + } + + /** Resolve the snapshot a contract is pinned to. */ + getPinnedState(contractId: string): SorokitResult { + const pin = this.pins.get(contractId); + if (!pin) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getPinnedState: contract ${contractId} is not pinned to any version.`, + ); + } + const snapshot = this.snapshots.get(pin.snapshotId); + if (!snapshot) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getPinnedState: pinned snapshot ${pin.snapshotId} is no longer available.`, + ); + } + return ok(snapshot); + } + + /** Remove a contract's pin. Returns true when one was removed. */ + unpinContractState(contractId: string): boolean { + return this.pins.delete(contractId); + } + + /** Look up one snapshot by id. */ + getSnapshot(snapshotId: string): SorokitResult { + const snapshot = this.snapshots.get(snapshotId); + if (!snapshot) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getSnapshot: snapshot ${snapshotId} was not found in this history.`, + ); + } + return ok(snapshot); + } + + /** + * Query captured snapshots, newest ledger first. + * + * Returns only what this history holds. An empty result means nothing was + * captured for that range — not that the contract had no state. + */ + querySnapshots(query: SnapshotQuery = {}): ContractStateSnapshotRecord[] { + return [...this.snapshots.values()] + .filter((snapshot) => { + if (query.contractId !== undefined && snapshot.contractId !== query.contractId) return false; + if (query.fromLedger !== undefined && snapshot.ledger < query.fromLedger) return false; + if (query.toLedger !== undefined && snapshot.ledger > query.toLedger) return false; + return true; + }) + .sort((a, b) => b.ledger - a.ledger || b.timestamp - a.timestamp); + } + + /** + * Return the snapshot for a contract at an exact ledger. + * + * Reports an explicit error when that ledger was never captured, so callers + * can distinguish "unavailable" from "empty state". + */ + getSnapshotAtLedger(contractId: string, ledger: number): SorokitResult { + const matches = this.querySnapshots({ contractId, fromLedger: ledger, toLedger: ledger }); + const snapshot = matches[0]; + if (!snapshot) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getSnapshotAtLedger: no snapshot captured for contract ${contractId} at ledger ${ledger}. ` + + "This SDK reports only snapshots it captured; it does not reconstruct historical ledger state.", + ); + } + return ok(snapshot); + } + + /** + * Recompute a snapshot's fingerprint and compare it to the stored value. + * + * A mismatch means the stored state was mutated after capture. + */ + verifySnapshotIntegrity(snapshotId: string): SorokitResult { + const found = this.getSnapshot(snapshotId); + if (found.status === "error") return found; + + const snapshot = found.data; + const actual = fingerprintState(snapshot.state as Record); + return ok({ + snapshotId, + valid: actual === snapshot.fingerprint, + expectedFingerprint: snapshot.fingerprint, + actualFingerprint: actual, + }); + } + + /** + * Compare two captured snapshots key by key. + * + * Both snapshots must belong to the same contract; comparing across contracts + * is rejected as a programming error rather than producing a diff of + * unrelated state. + */ + compareSnapshots(fromSnapshotId: string, toSnapshotId: string): SorokitResult { + const fromResult = this.getSnapshot(fromSnapshotId); + if (fromResult.status === "error") return fromResult; + const toResult = this.getSnapshot(toSnapshotId); + if (toResult.status === "error") return toResult; + + const from = fromResult.data; + const to = toResult.data; + + if (from.contractId !== to.contractId) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `compareSnapshots: snapshots belong to different contracts (${from.contractId} vs ${to.contractId}).`, + ); + } + + const changes: StateEntryChange[] = []; + const keys = new Set([...Object.keys(from.state), ...Object.keys(to.state)]); + + for (const key of [...keys].sort()) { + const hadKey = Object.prototype.hasOwnProperty.call(from.state, key); + const hasKey = Object.prototype.hasOwnProperty.call(to.state, key); + const before = from.state[key]; + const after = to.state[key]; + + if (!hadKey && hasKey) { + changes.push({ key, kind: "added", to: after }); + } else if (hadKey && !hasKey) { + changes.push({ key, kind: "removed", from: before }); + } else if (canonicalize(before) !== canonicalize(after)) { + changes.push({ key, kind: "changed", from: before, to: after }); + } + } + + return ok({ + contractId: from.contractId, + fromSnapshotId, + toSnapshotId, + fromLedger: from.ledger, + toLedger: to.ledger, + identical: from.fingerprint === to.fingerprint, + changes, + }); + } + + /** Discard all snapshots and pins. */ + clear(): void { + this.snapshots.clear(); + this.pins.clear(); + this.sequence = 0; + } +} + +/** Construct a {@link ContractStateHistory}. */ +export function createContractStateHistory(): ContractStateHistory { + return new ContractStateHistory(); +} diff --git a/src/soroban/index.ts b/src/soroban/index.ts index 0f0f763..7d29dd5 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -499,3 +499,19 @@ export type { StorageRecommendation, StorageAnalysisReport, } from "./storageAnalysis"; + +export { + ContractStateHistory, + createContractStateHistory, + fingerprintState, +} from "./contractStateHistory"; +export type { + ContractStateSnapshotRecord, + CaptureSnapshotInput, + ContractStatePin, + StateEntryChangeKind, + StateEntryChange, + ContractStateComparison, + SnapshotIntegrityReport, + SnapshotQuery, +} from "./contractStateHistory"; diff --git a/src/tests/contractStateHistory.test.ts b/src/tests/contractStateHistory.test.ts new file mode 100644 index 0000000..f64e858 --- /dev/null +++ b/src/tests/contractStateHistory.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { + createContractStateHistory, + fingerprintState, + ContractStateHistory, +} from "../soroban/contractStateHistory"; + +const CONTRACT_A = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; +const CONTRACT_B = "CDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3"; + +function historyWith(): ContractStateHistory { + return createContractStateHistory(); +} + +describe("fingerprintState", () => { + it("is stable for the same state", () => { + expect(fingerprintState({ a: 1, b: "two" })).toBe(fingerprintState({ a: 1, b: "two" })); + }); + + it("ignores key insertion order", () => { + expect(fingerprintState({ a: 1, b: 2 })).toBe(fingerprintState({ b: 2, a: 1 })); + }); + + it("ignores key order at nested depth", () => { + expect(fingerprintState({ outer: { x: 1, y: 2 } })).toBe( + fingerprintState({ outer: { y: 2, x: 1 } }), + ); + }); + + it("changes when a value changes", () => { + expect(fingerprintState({ a: 1 })).not.toBe(fingerprintState({ a: 2 })); + }); + + it("distinguishes a number from its string form", () => { + expect(fingerprintState({ a: 1 })).not.toBe(fingerprintState({ a: "1" })); + }); + + it("preserves array order", () => { + expect(fingerprintState({ a: [1, 2] })).not.toBe(fingerprintState({ a: [2, 1] })); + }); + + it("handles bigint values without throwing", () => { + expect(() => fingerprintState({ total: 10n })).not.toThrow(); + expect(fingerprintState({ total: 10n })).toBe(fingerprintState({ total: 10n })); + }); + + it("treats an empty state as valid", () => { + expect(fingerprintState({})).toMatch(/^[0-9a-f]{8}$/); + }); +}); + +describe("captureSnapshot", () => { + it("records contract id, ledger, timestamp and fingerprint", () => { + const history = historyWith(); + const result = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 100, + state: { counter: 1 }, + timestamp: 1_700_000_000_000, + }); + + expect(result.status).toBe("ok"); + expect(result.data).toMatchObject({ + contractId: CONTRACT_A, + ledger: 100, + timestamp: 1_700_000_000_000, + fingerprint: fingerprintState({ counter: 1 }), + }); + expect(result.data?.id).toBeTruthy(); + }); + + it("stores an optional label", () => { + const history = historyWith(); + const result = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: {}, + label: "pre-migration", + }); + + expect(result.data?.label).toBe("pre-migration"); + }); + + it("rejects an empty contract id", () => { + const result = historyWith().captureSnapshot({ contractId: " ", ledger: 1, state: {} }); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects a negative or non-integer ledger", () => { + const history = historyWith(); + expect(history.captureSnapshot({ contractId: CONTRACT_A, ledger: -1, state: {} }).status).toBe( + "error", + ); + expect(history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1.5, state: {} }).status).toBe( + "error", + ); + }); + + it("copies the supplied state so later mutation cannot alter the snapshot", () => { + const history = historyWith(); + const state: Record = { counter: 1 }; + const snapshot = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state }); + + state["counter"] = 999; + + expect(snapshot.data?.state["counter"]).toBe(1); + expect(history.verifySnapshotIntegrity(snapshot.data!.id).data?.valid).toBe(true); + }); + + it("supports multiple snapshots for the same contract", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 2, state: { v: 2 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 3, state: { v: 3 } }); + + expect(history.querySnapshots({ contractId: CONTRACT_A })).toHaveLength(3); + }); + + it("gives distinct ids to two captures at the same ledger", () => { + const history = historyWith(); + const first = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 1 } }); + const second = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 2 } }); + + expect(first.data?.id).not.toBe(second.data?.id); + }); +}); + +describe("querySnapshots", () => { + function seeded(): ContractStateHistory { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 10, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 20, state: { v: 2 } }); + history.captureSnapshot({ contractId: CONTRACT_B, ledger: 30, state: { v: 3 } }); + return history; + } + + it("filters by contract", () => { + expect(seeded().querySnapshots({ contractId: CONTRACT_B })).toHaveLength(1); + }); + + it("filters by ledger range inclusively", () => { + const found = seeded().querySnapshots({ contractId: CONTRACT_A, fromLedger: 10, toLedger: 10 }); + expect(found.map((s) => s.ledger)).toEqual([10]); + }); + + it("returns newest ledger first", () => { + expect(seeded().querySnapshots().map((s) => s.ledger)).toEqual([30, 20, 10]); + }); + + it("returns an empty list when nothing matches", () => { + expect(seeded().querySnapshots({ contractId: "CUNKNOWN" })).toEqual([]); + }); +}); + +describe("getSnapshotAtLedger", () => { + it("returns the snapshot captured at that ledger", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 42, state: { v: 1 } }); + + expect(history.getSnapshotAtLedger(CONTRACT_A, 42).data?.ledger).toBe(42); + }); + + it("reports unavailable history clearly rather than returning empty state", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 42, state: { v: 1 } }); + const result = history.getSnapshotAtLedger(CONTRACT_A, 41); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("CONTRACT_READ_FAILED"); + expect(result.error?.message).toContain("does not reconstruct historical ledger state"); + }); +}); + +describe("pinContractState", () => { + it("pins a contract to a captured version", () => { + const history = historyWith(); + const snapshot = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 7, state: { v: 1 } }); + const pin = history.pinContractState(CONTRACT_A, 7); + + expect(pin.status).toBe("ok"); + expect(pin.data).toMatchObject({ contractId: CONTRACT_A, snapshotId: snapshot.data!.id, ledger: 7 }); + }); + + it("resolves the pinned snapshot", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 7, state: { v: 1 } }); + history.pinContractState(CONTRACT_A, 7); + + expect(history.getPinnedState(CONTRACT_A).data?.state).toEqual({ v: 1 }); + }); + + it("refuses to pin a ledger that was never captured", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 7, state: { v: 1 } }); + const result = history.pinContractState(CONTRACT_A, 8); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("no snapshot captured"); + }); + + it("does not fall back to the nearest ledger when the exact one is missing", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 9, state: { v: 2 } }); + + expect(history.pinContractState(CONTRACT_A, 7).status).toBe("error"); + }); + + it("pins the most recent capture when a ledger was captured twice", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 1 }, timestamp: 1 }); + const later = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 5, + state: { v: 2 }, + timestamp: 2, + }); + + expect(history.pinContractState(CONTRACT_A, 5).data?.snapshotId).toBe(later.data?.id); + }); + + it("reports an unpinned contract", () => { + const result = historyWith().getPinnedState(CONTRACT_A); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not pinned"); + }); + + it("moves the pin when re-pinned to another version", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 2, state: { v: 2 } }); + history.pinContractState(CONTRACT_A, 1); + history.pinContractState(CONTRACT_A, 2); + + expect(history.getPinnedState(CONTRACT_A).data?.ledger).toBe(2); + }); + + it("unpins a contract", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + history.pinContractState(CONTRACT_A, 1); + + expect(history.unpinContractState(CONTRACT_A)).toBe(true); + expect(history.unpinContractState(CONTRACT_A)).toBe(false); + expect(history.getPin(CONTRACT_A)).toBeUndefined(); + }); + + it("keeps pins independent across contracts", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { a: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_B, ledger: 2, state: { b: 1 } }); + history.pinContractState(CONTRACT_A, 1); + history.pinContractState(CONTRACT_B, 2); + + expect(history.getPinnedState(CONTRACT_A).data?.state).toEqual({ a: 1 }); + expect(history.getPinnedState(CONTRACT_B).data?.state).toEqual({ b: 1 }); + }); +}); + +describe("verifySnapshotIntegrity", () => { + it("validates an untouched snapshot", () => { + const history = historyWith(); + const snapshot = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: { balance: 500 }, + }); + const report = history.verifySnapshotIntegrity(snapshot.data!.id); + + expect(report.data?.valid).toBe(true); + expect(report.data?.actualFingerprint).toBe(report.data?.expectedFingerprint); + }); + + it("reports an unknown snapshot as an error", () => { + const result = historyWith().verifySnapshotIntegrity("missing"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("CONTRACT_READ_FAILED"); + }); +}); + +describe("compareSnapshots", () => { + it("detects added, removed and changed entries", () => { + const history = historyWith(); + const before = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: { kept: 1, dropped: 2, moved: 3 }, + }); + const after = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 2, + state: { kept: 1, moved: 4, fresh: 5 }, + }); + + const comparison = history.compareSnapshots(before.data!.id, after.data!.id); + + expect(comparison.data?.identical).toBe(false); + expect(comparison.data?.changes).toEqual([ + { key: "dropped", kind: "removed", from: 2 }, + { key: "fresh", kind: "added", to: 5 }, + { key: "moved", kind: "changed", from: 3, to: 4 }, + ]); + }); + + it("reports identical snapshots with no changes", () => { + const history = historyWith(); + const first = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { v: 1 } }); + const second = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 2, state: { v: 1 } }); + + const comparison = history.compareSnapshots(first.data!.id, second.data!.id); + + expect(comparison.data?.identical).toBe(true); + expect(comparison.data?.changes).toEqual([]); + }); + + it("does not report a change when only nested key order differs", () => { + const history = historyWith(); + const first = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: { cfg: { a: 1, b: 2 } }, + }); + const second = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 2, + state: { cfg: { b: 2, a: 1 } }, + }); + + expect(history.compareSnapshots(first.data!.id, second.data!.id).data?.changes).toEqual([]); + }); + + it("carries both ledger references in the comparison", () => { + const history = historyWith(); + const first = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 10, state: {} }); + const second = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 20, state: {} }); + + expect(history.compareSnapshots(first.data!.id, second.data!.id).data).toMatchObject({ + fromLedger: 10, + toLedger: 20, + contractId: CONTRACT_A, + }); + }); + + it("rejects a comparison across two different contracts", () => { + const history = historyWith(); + const a = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + const b = history.captureSnapshot({ contractId: CONTRACT_B, ledger: 1, state: {} }); + + const result = history.compareSnapshots(a.data!.id, b.data!.id); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("different contracts"); + }); + + it("reports an unknown snapshot id", () => { + const history = historyWith(); + const known = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + + expect(history.compareSnapshots("missing", known.data!.id).status).toBe("error"); + expect(history.compareSnapshots(known.data!.id, "missing").status).toBe("error"); + }); +}); + +describe("clear", () => { + it("discards snapshots and pins", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + history.pinContractState(CONTRACT_A, 1); + history.clear(); + + expect(history.querySnapshots()).toEqual([]); + expect(history.getPin(CONTRACT_A)).toBeUndefined(); + }); +}); From 7eba1f275055196fdcde6f59c71e62d92fffc794 Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:49:59 +0100 Subject: [PATCH 3/5] feat(soroban): add multi-signature contract execution workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N-of-M contract authorization required callers to coordinate signature collection and assemble the final envelope by hand. This adds MultiSigContractExecution, which separates preparation, signing-request creation, signature collection, validation and submission. The canonical signing payload is the transaction hash for the request's network passphrase — the same payload the Stellar protocol signs — so no independent signing format is introduced. Signatures are verified with Keypair.verify before being accepted, matching the verification approach already used in submitTransaction.ts. Because the payload is network-bound, a request cannot be replayed against a different network or a modified transaction body. Signatures that fail verification are rejected and contribute no weight, so an invalid signature can never advance the threshold. Duplicate submissions from the same signer are rejected, preventing one signer from satisfying a multi-signer threshold alone. Thresholds accrue by signer weight rather than signer count, and a threshold exceeding total declared weight is rejected at creation time as unreachable. Expired requests can neither collect signatures nor execute, even when the threshold was met before expiry. Executing marks the request executed so the same authorization cannot be assembled twice. execute() returns the assembled signed XDR for submitTransaction(); the workflow never signs or submits on the caller's behalf. --- src/index.ts | 12 + src/soroban/index.ts | 13 + src/soroban/multiSigExecution.ts | 425 ++++++++++++++++++++++++ src/tests/multiSigExecution.test.ts | 498 ++++++++++++++++++++++++++++ 4 files changed, 948 insertions(+) create mode 100644 src/soroban/multiSigExecution.ts create mode 100644 src/tests/multiSigExecution.test.ts diff --git a/src/index.ts b/src/index.ts index 2c0c548..fdafec9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -875,3 +875,15 @@ export type { CongestionLevel, CongestionSnapshot, } from "./network/congestionMonitor"; +export { + MultiSigContractExecution, + createMultiSigContractExecution, +} from "./soroban/multiSigExecution"; +export type { + ContractExecutionSigner, + CreateSigningRequestInput, + CollectedSignature, + SigningRequestStatus, + ContractSigningRequest, + SigningRequestState, +} from "./soroban/multiSigExecution"; diff --git a/src/soroban/index.ts b/src/soroban/index.ts index 0f0f763..02adc7c 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -499,3 +499,16 @@ export type { StorageRecommendation, StorageAnalysisReport, } from "./storageAnalysis"; + +export { + MultiSigContractExecution, + createMultiSigContractExecution, +} from "./multiSigExecution"; +export type { + ContractExecutionSigner, + CreateSigningRequestInput, + CollectedSignature, + SigningRequestStatus, + ContractSigningRequest, + SigningRequestState, +} from "./multiSigExecution"; diff --git a/src/soroban/multiSigExecution.ts b/src/soroban/multiSigExecution.ts new file mode 100644 index 0000000..c9c15e5 --- /dev/null +++ b/src/soroban/multiSigExecution.ts @@ -0,0 +1,425 @@ +import { Keypair, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; +import { toMessage } from "../shared"; + +// ─── Types ─── + +/** A signer permitted to authorize a contract execution request. */ +export interface ContractExecutionSigner { + publicKey: string; + /** Weight this signer contributes toward the threshold. Defaults to 1. */ + weight?: number; +} + +export interface CreateSigningRequestInput { + /** Prepared, unsigned contract invocation XDR. */ + transactionXdr: string; + networkPassphrase: string; + signers: readonly ContractExecutionSigner[]; + /** Total weight required before execution is permitted. */ + threshold: number; + /** Epoch milliseconds after which the request can no longer be executed. */ + expiresAt?: number; + /** Epoch milliseconds treated as creation time. Defaults to `Date.now()`. */ + now?: number; +} + +/** A signature contributed by one signer. */ +export interface CollectedSignature { + publicKey: string; + /** Base64-encoded Ed25519 signature over the canonical payload. */ + signature: string; + weight: number; + collectedAt: number; +} + +export type SigningRequestStatus = "collecting" | "ready" | "expired" | "executed"; + +/** + * A pending N-of-M contract execution. + * + * `payloadHash` is the canonical signing payload: the transaction hash for the + * given network passphrase. Every signature is verified against this exact + * value, so the request cannot be replayed against a different network or a + * different transaction body. + */ +export interface ContractSigningRequest { + id: string; + transactionXdr: string; + networkPassphrase: string; + /** Hex-encoded transaction hash that signers must sign. */ + payloadHash: string; + signers: readonly Required[]; + threshold: number; + signatures: readonly CollectedSignature[]; + collectedWeight: number; + status: SigningRequestStatus; + createdAt: number; + expiresAt?: number; +} + +export interface SigningRequestState { + id: string; + status: SigningRequestStatus; + collectedWeight: number; + threshold: number; + /** Weight still needed. Zero once the threshold is satisfied. */ + remainingWeight: number; + thresholdMet: boolean; + signedBy: readonly string[]; + pendingSigners: readonly string[]; + expiresAt?: number; +} + +// ─── Helpers ─── + +function normalizeSigners( + signers: readonly ContractExecutionSigner[], +): Required[] { + return signers.map((signer) => ({ publicKey: signer.publicKey, weight: signer.weight ?? 1 })); +} + +/** + * Compute the canonical payload signers must sign: the transaction hash bound + * to the network passphrase. + */ +function computePayloadHash( + transactionXdr: string, + networkPassphrase: string, +): SorokitResult { + try { + const transaction = TransactionBuilder.fromXDR(transactionXdr, networkPassphrase); + return ok(transaction.hash()); + } catch (cause) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `Signing request payload could not be derived — ${toMessage(cause)}`, + cause, + ); + } +} + +function isExpired(request: ContractSigningRequest, now: number): boolean { + return request.expiresAt !== undefined && now >= request.expiresAt; +} + +function recompute(request: ContractSigningRequest, now: number): ContractSigningRequest { + if (request.status === "executed") return request; + if (isExpired(request, now)) return { ...request, status: "expired" }; + return { + ...request, + status: request.collectedWeight >= request.threshold ? "ready" : "collecting", + }; +} + +// ─── Workflow ─── + +/** + * Coordinates N-of-M authorization for a Soroban contract invocation. + * + * The workflow separates preparation, signature collection, validation and + * submission. It never signs: callers sign the request's `payloadHash` with + * their own wallet or keypair and submit the resulting signature here. + * + * Signatures are verified as Ed25519 signatures over the transaction hash — the + * same payload the Stellar protocol itself signs — so no separate signing + * format is introduced. + */ +export class MultiSigContractExecution { + private readonly requests = new Map(); + private sequence = 0; + + /** + * Create a signing request for a prepared contract invocation. + * + * Validates that the XDR parses for the given network, that signers are + * unique and positively weighted, and that the threshold is reachable. + */ + createSigningRequest(input: CreateSigningRequestInput): SorokitResult { + const signers = normalizeSigners(input.signers); + + if (signers.length === 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "createSigningRequest: at least one signer is required.", + ); + } + + const seen = new Set(); + for (const signer of signers) { + if (seen.has(signer.publicKey)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: signer ${signer.publicKey} is listed more than once.`, + ); + } + seen.add(signer.publicKey); + + if (signer.weight < 1) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: signer ${signer.publicKey} has invalid weight ${signer.weight} — must be >= 1.`, + ); + } + try { + Keypair.fromPublicKey(signer.publicKey); + } catch { + return err( + SorokitErrorCode.INVALID_ADDRESS, + `createSigningRequest: ${signer.publicKey} is not a valid Stellar public key.`, + ); + } + } + + if (!Number.isFinite(input.threshold) || input.threshold < 1) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: threshold must be >= 1 (got ${input.threshold}).`, + ); + } + + const totalWeight = signers.reduce((sum, signer) => sum + signer.weight, 0); + if (input.threshold > totalWeight) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: threshold (${input.threshold}) exceeds total signer weight (${totalWeight}) and can never be met.`, + ); + } + + const payload = computePayloadHash(input.transactionXdr, input.networkPassphrase); + if (payload.status === "error") return payload; + + const now = input.now ?? Date.now(); + if (input.expiresAt !== undefined && input.expiresAt <= now) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "createSigningRequest: expiresAt must be in the future.", + ); + } + + this.sequence += 1; + const request: ContractSigningRequest = { + id: `csr-${this.sequence}-${payload.data.toString("hex").slice(0, 8)}`, + transactionXdr: input.transactionXdr, + networkPassphrase: input.networkPassphrase, + payloadHash: payload.data.toString("hex"), + signers, + threshold: input.threshold, + signatures: [], + collectedWeight: 0, + status: "collecting", + createdAt: now, + ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}), + }; + + this.requests.set(request.id, request); + return ok(request); + } + + /** + * Add a signature to a request. + * + * The signature must be a valid Ed25519 signature by `publicKey` over the + * request's `payloadHash`. Invalid signatures are rejected and contribute no + * weight. A signer that has already contributed is rejected, so one signer + * cannot satisfy a multi-signer threshold alone. + */ + addSignature( + requestId: string, + publicKey: string, + signature: string, + now: number = Date.now(), + ): SorokitResult { + const existing = this.requests.get(requestId); + if (!existing) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `addSignature: unknown signing request "${requestId}".`, + ); + } + + const request = recompute(existing, now); + this.requests.set(requestId, request); + + if (request.status === "executed") { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + `addSignature: request "${requestId}" has already been executed.`, + ); + } + if (request.status === "expired") { + return err( + SorokitErrorCode.OPERATION_TIMEOUT, + `addSignature: request "${requestId}" expired and can no longer collect signatures.`, + ); + } + + const signer = request.signers.find((entry) => entry.publicKey === publicKey); + if (!signer) { + return err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `addSignature: ${publicKey} is not a declared signer on this request.`, + ); + } + if (request.signatures.some((entry) => entry.publicKey === publicKey)) { + return err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `addSignature: ${publicKey} has already signed this request.`, + ); + } + + if (!this.isSignatureValid(request, publicKey, signature)) { + return err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `addSignature: signature from ${publicKey} is not a valid signature over this request payload.`, + ); + } + + const signatures = [ + ...request.signatures, + { publicKey, signature, weight: signer.weight, collectedAt: now }, + ]; + const collectedWeight = signatures.reduce((sum, entry) => sum + entry.weight, 0); + const updated = recompute({ ...request, signatures, collectedWeight }, now); + + this.requests.set(requestId, updated); + return ok(updated); + } + + private isSignatureValid( + request: ContractSigningRequest, + publicKey: string, + signature: string, + ): boolean { + try { + const keypair = Keypair.fromPublicKey(publicKey); + const payload = Buffer.from(request.payloadHash, "hex"); + return keypair.verify(payload, Buffer.from(signature, "base64")); + } catch { + return false; + } + } + + /** Current collection state of a request. */ + getRequestState(requestId: string, now: number = Date.now()): SorokitResult { + const existing = this.requests.get(requestId); + if (!existing) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `getRequestState: unknown signing request "${requestId}".`, + ); + } + + const request = recompute(existing, now); + this.requests.set(requestId, request); + + const signedBy = request.signatures.map((entry) => entry.publicKey); + return ok({ + id: request.id, + status: request.status, + collectedWeight: request.collectedWeight, + threshold: request.threshold, + remainingWeight: Math.max(0, request.threshold - request.collectedWeight), + thresholdMet: request.collectedWeight >= request.threshold, + signedBy, + pendingSigners: request.signers + .filter((signer) => !signedBy.includes(signer.publicKey)) + .map((signer) => signer.publicKey), + ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), + }); + } + + /** Retrieve the full request record. */ + getRequest(requestId: string): ContractSigningRequest | undefined { + return this.requests.get(requestId); + } + + /** List every tracked request. */ + listRequests(): ContractSigningRequest[] { + return [...this.requests.values()]; + } + + /** + * Assemble the fully-signed transaction XDR once the threshold is met. + * + * Execution is blocked while the request is still collecting, expired, or + * already executed. On success the collected signatures are attached to the + * envelope and the request is marked executed, so the same authorization + * cannot be assembled twice. + * + * @returns `ok(signedXdr)` ready to pass to submitTransaction(). + */ + execute(requestId: string, now: number = Date.now()): SorokitResult { + const existing = this.requests.get(requestId); + if (!existing) { + return err(SorokitErrorCode.INVALID_CONFIG, `execute: unknown signing request "${requestId}".`); + } + + const request = recompute(existing, now); + this.requests.set(requestId, request); + + if (request.status === "executed") { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + `execute: request "${requestId}" has already been executed.`, + ); + } + if (request.status === "expired") { + return err( + SorokitErrorCode.OPERATION_TIMEOUT, + `execute: request "${requestId}" expired before the threshold was met and cannot be executed.`, + ); + } + if (request.collectedWeight < request.threshold) { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + `execute: threshold not met — ${request.threshold - request.collectedWeight} more weight required ` + + `(collected ${request.collectedWeight}/${request.threshold}).`, + ); + } + + let signedXdr: string; + try { + const transaction = TransactionBuilder.fromXDR( + request.transactionXdr, + request.networkPassphrase, + ); + for (const entry of request.signatures) { + const hint = Keypair.fromPublicKey(entry.publicKey).signatureHint(); + transaction.signatures.push( + new xdr.DecoratedSignature({ + hint, + signature: Buffer.from(entry.signature, "base64"), + }), + ); + } + signedXdr = transaction.toXDR(); + } catch (cause) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `execute: failed to assemble the signed envelope — ${toMessage(cause)}`, + cause, + ); + } + + this.requests.set(requestId, { ...request, status: "executed" }); + return ok(signedXdr); + } + + /** Discard a request. Returns true when one was removed. */ + cancelRequest(requestId: string): boolean { + return this.requests.delete(requestId); + } + + /** Discard all tracked requests. */ + clear(): void { + this.requests.clear(); + this.sequence = 0; + } +} + +/** Construct a {@link MultiSigContractExecution} workflow. */ +export function createMultiSigContractExecution(): MultiSigContractExecution { + return new MultiSigContractExecution(); +} diff --git a/src/tests/multiSigExecution.test.ts b/src/tests/multiSigExecution.test.ts new file mode 100644 index 0000000..e4d5901 --- /dev/null +++ b/src/tests/multiSigExecution.test.ts @@ -0,0 +1,498 @@ +import { + Account, + Keypair, + Networks, + Operation, + TransactionBuilder, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import { describe, expect, it } from "vitest"; +import { + createMultiSigContractExecution, + MultiSigContractExecution, +} from "../soroban/multiSigExecution"; +import type { ContractSigningRequest } from "../soroban/multiSigExecution"; + +const NETWORK = Networks.TESTNET; + +// Deterministic signers so failures are reproducible. +const SIGNER_A = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)); +const SIGNER_B = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)); +const SIGNER_C = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)); +const OUTSIDER = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)); + +/** Build an unsigned transaction to stand in for a prepared contract invocation. */ +function buildUnsignedXdr(): string { + const source = new Account(SIGNER_A.publicKey(), "1"); + return new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase: NETWORK }) + .addOperation(Operation.bumpSequence({ bumpTo: "2" })) + .setTimeout(0) + .build() + .toXDR(); +} + +const UNSIGNED_XDR = buildUnsignedXdr(); + +/** Sign a request's canonical payload the way a wallet would. */ +function sign(request: ContractSigningRequest, keypair: Keypair): string { + return keypair.sign(Buffer.from(request.payloadHash, "hex")).toString("base64"); +} + +function workflow(): MultiSigContractExecution { + return createMultiSigContractExecution(); +} + +function newRequest( + execution: MultiSigContractExecution, + overrides: Partial[0]> = {}, +): ContractSigningRequest { + const result = execution.createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [ + { publicKey: SIGNER_A.publicKey() }, + { publicKey: SIGNER_B.publicKey() }, + { publicKey: SIGNER_C.publicKey() }, + ], + threshold: 2, + ...overrides, + }); + if (result.status === "error") throw new Error(result.error.message); + return result.data; +} + +describe("createSigningRequest", () => { + it("creates a request carrying a canonical payload derived from the transaction", () => { + const request = newRequest(workflow()); + const expected = TransactionBuilder.fromXDR(UNSIGNED_XDR, NETWORK).hash().toString("hex"); + + expect(request.payloadHash).toBe(expected); + expect(request.status).toBe("collecting"); + expect(request.collectedWeight).toBe(0); + }); + + it("supports N-of-M configuration with explicit weights", () => { + const request = newRequest(workflow(), { + signers: [ + { publicKey: SIGNER_A.publicKey(), weight: 2 }, + { publicKey: SIGNER_B.publicKey(), weight: 1 }, + ], + threshold: 3, + }); + + expect(request.signers).toEqual([ + { publicKey: SIGNER_A.publicKey(), weight: 2 }, + { publicKey: SIGNER_B.publicKey(), weight: 1 }, + ]); + expect(request.threshold).toBe(3); + }); + + it("defaults an omitted signer weight to 1", () => { + expect(newRequest(workflow()).signers.every((s) => s.weight === 1)).toBe(true); + }); + + it("rejects an empty signer list", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("at least one signer"); + }); + + it("rejects a duplicate signer in the configuration", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }, { publicKey: SIGNER_A.publicKey() }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("listed more than once"); + }); + + it("rejects an unreachable threshold", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 5, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("can never be met"); + }); + + it("rejects a threshold below 1", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 0, + }); + + expect(result.status).toBe("error"); + }); + + it("rejects a non-positive signer weight", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey(), weight: 0 }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("invalid weight"); + }); + + it("rejects a malformed signer public key", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: "not-a-key" }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_ADDRESS"); + }); + + it("rejects malformed transaction XDR", () => { + const result = workflow().createSigningRequest({ + transactionXdr: "garbage", + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("TX_BUILD_FAILED"); + }); + + it("rejects an expiry that is already in the past", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 1, + now: 1_000, + expiresAt: 500, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("must be in the future"); + }); + + it("binds the payload to the network passphrase", () => { + const testnet = newRequest(workflow()); + const futurenet = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: Networks.FUTURENET, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 1, + }); + + expect(futurenet.data?.payloadHash).not.toBe(testnet.payloadHash); + }); +}); + +describe("addSignature", () => { + it("accepts a valid signature and credits its weight", () => { + const execution = workflow(); + const request = newRequest(execution); + const result = execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(result.status).toBe("ok"); + expect(result.data?.collectedWeight).toBe(1); + expect(result.data?.signatures).toHaveLength(1); + }); + + it("rejects a signature that does not verify and credits no weight", () => { + const execution = workflow(); + const request = newRequest(execution); + + // A real signature, but produced by a different key than the one claimed. + const forged = sign(request, OUTSIDER); + const result = execution.addSignature(request.id, SIGNER_A.publicKey(), forged); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not a valid signature"); + expect(execution.getRequestState(request.id).data?.collectedWeight).toBe(0); + }); + + it("rejects a signature over a different payload", () => { + const execution = workflow(); + const request = newRequest(execution); + const wrongPayload = SIGNER_A.sign(Buffer.from("some other payload")).toString("base64"); + + expect(execution.addSignature(request.id, SIGNER_A.publicKey(), wrongPayload).status).toBe( + "error", + ); + }); + + it("rejects structurally invalid signature bytes", () => { + const execution = workflow(); + const request = newRequest(execution); + + expect(execution.addSignature(request.id, SIGNER_A.publicKey(), "!!!not-base64!!!").status).toBe( + "error", + ); + }); + + it("rejects a signer that is not declared on the request", () => { + const execution = workflow(); + const request = newRequest(execution); + const result = execution.addSignature( + request.id, + OUTSIDER.publicKey(), + sign(request, OUTSIDER), + ); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not a declared signer"); + }); + + it("rejects a duplicate submission from the same signer", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + const duplicate = execution.addSignature( + request.id, + SIGNER_A.publicKey(), + sign(request, SIGNER_A), + ); + + expect(duplicate.status).toBe("error"); + expect(duplicate.error?.message).toContain("already signed"); + expect(execution.getRequestState(request.id).data?.collectedWeight).toBe(1); + }); + + it("does not let one signer alone satisfy a two-signer threshold", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(execution.getRequestState(request.id).data?.thresholdMet).toBe(false); + expect(execution.execute(request.id).status).toBe("error"); + }); + + it("marks the request ready once the threshold is met", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + const second = execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + + expect(second.data?.status).toBe("ready"); + expect(second.data?.collectedWeight).toBe(2); + }); + + it("reaches the threshold on weight, not signer count", () => { + const execution = workflow(); + const request = newRequest(execution, { + signers: [ + { publicKey: SIGNER_A.publicKey(), weight: 3 }, + { publicKey: SIGNER_B.publicKey(), weight: 1 }, + ], + threshold: 3, + }); + const result = execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(result.data?.status).toBe("ready"); + }); + + it("rejects a signature on an unknown request", () => { + expect(workflow().addSignature("missing", SIGNER_A.publicKey(), "sig").status).toBe("error"); + }); + + it("refuses signatures once the request has expired", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + const result = execution.addSignature( + request.id, + SIGNER_A.publicKey(), + sign(request, SIGNER_A), + 1_001, + ); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("OPERATION_TIMEOUT"); + }); +}); + +describe("getRequestState", () => { + it("reports collection progress and who is still pending", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(execution.getRequestState(request.id).data).toMatchObject({ + status: "collecting", + collectedWeight: 1, + threshold: 2, + remainingWeight: 1, + thresholdMet: false, + signedBy: [SIGNER_A.publicKey()], + pendingSigners: [SIGNER_B.publicKey(), SIGNER_C.publicKey()], + }); + }); + + it("clamps remaining weight at zero once satisfied", () => { + const execution = workflow(); + const request = newRequest(execution, { + signers: [{ publicKey: SIGNER_A.publicKey(), weight: 5 }], + threshold: 2, + }); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(execution.getRequestState(request.id).data?.remainingWeight).toBe(0); + }); + + it("transitions to expired once the deadline passes", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + + expect(execution.getRequestState(request.id, 999).data?.status).toBe("collecting"); + expect(execution.getRequestState(request.id, 1_000).data?.status).toBe("expired"); + }); + + it("reports an unknown request as an error", () => { + expect(workflow().getRequestState("missing").status).toBe("error"); + }); +}); + +describe("execute", () => { + it("blocks execution while the threshold is unmet", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + const result = execution.execute(request.id); + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("threshold not met"); + }); + + it("blocks execution with no signatures at all", () => { + const execution = workflow(); + expect(execution.execute(newRequest(execution).id).status).toBe("error"); + }); + + it("assembles a signed envelope carrying every collected signature", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + + const result = execution.execute(request.id); + expect(result.status).toBe("ok"); + + const signed = TransactionBuilder.fromXDR(result.data!, NETWORK); + expect(signed.signatures).toHaveLength(2); + }); + + it("produces signatures that verify against the signers", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + + const signed = TransactionBuilder.fromXDR(execution.execute(request.id).data!, NETWORK); + const payload = signed.hash(); + + for (const keypair of [SIGNER_A, SIGNER_B]) { + const match = signed.signatures.find((s) => + s.hint().equals(keypair.signatureHint()), + ); + expect(match).toBeDefined(); + expect(keypair.verify(payload, match!.signature())).toBe(true); + } + }); + + it("marks the request executed and refuses a second execution", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + execution.execute(request.id); + + expect(execution.getRequestState(request.id).data?.status).toBe("executed"); + const again = execution.execute(request.id); + expect(again.status).toBe("error"); + expect(again.error?.message).toContain("already been executed"); + }); + + it("refuses further signatures after execution", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + execution.execute(request.id); + + const late = execution.addSignature(request.id, SIGNER_C.publicKey(), sign(request, SIGNER_C)); + expect(late.status).toBe("error"); + expect(late.error?.message).toContain("already been executed"); + }); + + it("refuses to execute an expired request even when the threshold was met", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A), 10); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B), 20); + + expect(execution.getRequestState(request.id, 20).data?.thresholdMet).toBe(true); + + const result = execution.execute(request.id, 1_001); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("OPERATION_TIMEOUT"); + }); + + it("executes normally before the deadline", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A), 10); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B), 20); + + expect(execution.execute(request.id, 999).status).toBe("ok"); + }); + + it("reports an unknown request", () => { + expect(workflow().execute("missing").status).toBe("error"); + }); +}); + +describe("request lifecycle", () => { + it("lists and cancels tracked requests", () => { + const execution = workflow(); + const request = newRequest(execution); + + expect(execution.listRequests()).toHaveLength(1); + expect(execution.cancelRequest(request.id)).toBe(true); + expect(execution.cancelRequest(request.id)).toBe(false); + expect(execution.getRequest(request.id)).toBeUndefined(); + }); + + it("keeps concurrent requests independent", () => { + const execution = workflow(); + const first = newRequest(execution); + const second = newRequest(execution); + + expect(first.id).not.toBe(second.id); + execution.addSignature(first.id, SIGNER_A.publicKey(), sign(first, SIGNER_A)); + + expect(execution.getRequestState(first.id).data?.collectedWeight).toBe(1); + expect(execution.getRequestState(second.id).data?.collectedWeight).toBe(0); + }); + + it("clears all tracked requests", () => { + const execution = workflow(); + newRequest(execution); + execution.clear(); + + expect(execution.listRequests()).toEqual([]); + }); +}); From 66c34d6c6bc687a0cbe6a6f602f41dcf5311c79e Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:53:12 +0100 Subject: [PATCH 4/5] feat(wallet): add connection security auditing and risk assessment Applications had no consistent way to judge whether a wallet adapter supports expected security properties or whether a connection carries known risk. This adds auditWalletSecurity, which returns a structured report rather than a single opaque number. The report exposes every contributing factor with its severity, confidence and exact score penalty, so callers can act on the underlying evidence. The 0-100 score is derived deterministically from a fixed severity-to-penalty table: identical inputs always produce an identical score, and the score always equals the base of 100 plus the sum of the factor deltas. Assessment covers declared adapter capabilities, adapter availability, authentication state, connection origin (HTTPS, localhost HTTP, plain HTTP and unparseable origins are distinguished), and vulnerability data from a caller-supplied source. Unknown is never treated as safe. With no configured source, or a source whose knownWallets does not cover this wallet, vulnerability status is reported as unknown, penalized, and vulnerabilityDataAvailable is false. An unknown adapter version causes version-scoped advisories to be treated as applicable rather than excluded. Undated and stale sources are penalized. A clean result states explicitly that absence of a report is not proof of safety. The score's limits are documented on the function: it reflects only what was observable at audit time, capabilities are self-declared by the adapter and are not verified, and the number is not a safety guarantee. --- src/index.ts | 12 + src/tests/walletSecurityAudit.test.ts | 487 ++++++++++++++++++++++++++ src/wallet/index.ts | 13 + src/wallet/securityAudit.ts | 458 ++++++++++++++++++++++++ 4 files changed, 970 insertions(+) create mode 100644 src/tests/walletSecurityAudit.test.ts create mode 100644 src/wallet/securityAudit.ts diff --git a/src/index.ts b/src/index.ts index 2c0c548..51693d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -875,3 +875,15 @@ export type { CongestionLevel, CongestionSnapshot, } from "./network/congestionMonitor"; +export { auditWalletSecurity, isHighRiskConnection } from "./wallet/securityAudit"; +export type { + RiskSeverity, + RiskConfidence, + RiskFactor, + WalletVulnerability, + VulnerabilitySource, + WalletConnectionContext, + WalletSecurityAuditOptions, + RiskLevel, + WalletSecurityReport, +} from "./wallet/securityAudit"; diff --git a/src/tests/walletSecurityAudit.test.ts b/src/tests/walletSecurityAudit.test.ts new file mode 100644 index 0000000..e638d7f --- /dev/null +++ b/src/tests/walletSecurityAudit.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it } from "vitest"; +import { auditWalletSecurity, isHighRiskConnection } from "../wallet/securityAudit"; +import type { VulnerabilitySource, WalletSecurityAuditOptions } from "../wallet/securityAudit"; +import { WalletType } from "../wallet/types"; +import type { WalletAdapter, WalletCapabilities, WalletCapabilityId } from "../wallet/types"; +import { ok } from "../shared/response"; + +const NOW = Date.UTC(2026, 4, 15); +const DAY = 24 * 60 * 60 * 1000; + +function capabilities( + walletType: WalletType, + supported: readonly WalletCapabilityId[], +): WalletCapabilities { + const ids: WalletCapabilityId[] = [ + "account.read", + "transaction.sign", + "transaction.sign_multisig", + "transaction.sign_soroban", + ]; + const list = ids.map((id) => ({ + id, + supported: supported.includes(id), + source: "adapter" as const, + })); + return { + walletType, + capabilities: list, + supports: (capability: string) => + list.some((entry) => entry.id === capability && entry.supported), + }; +} + +interface AdapterOverrides { + walletType?: WalletType; + available?: boolean; + supported?: readonly WalletCapabilityId[]; + getCapabilities?: () => WalletCapabilities; +} + +function makeAdapter(overrides: AdapterOverrides = {}): WalletAdapter { + const walletType = overrides.walletType ?? WalletType.FREIGHTER; + const supported = overrides.supported ?? [ + "account.read", + "transaction.sign", + "transaction.sign_multisig", + "transaction.sign_soroban", + ]; + return { + walletType, + isAvailable: () => overrides.available ?? true, + connect: async () => ok("GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"), + disconnect: async () => ok(undefined), + signTransaction: async () => ok("signed"), + getCapabilities: overrides.getCapabilities ?? (() => capabilities(walletType, supported)), + }; +} + +const CLEAN_SOURCE: VulnerabilitySource = { + name: "test-advisories", + vulnerabilities: [], + knownWallets: [WalletType.FREIGHTER, WalletType.XBULL], + updatedAt: NOW, +}; + +/** A fully healthy configuration: HTTPS, full capabilities, fresh clean source. */ +function secureOptions(): WalletSecurityAuditOptions { + return { + now: NOW, + connection: { + connected: true, + publicKey: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + origin: "https://app.example.com", + adapterVersion: "1.2.3", + }, + vulnerabilitySource: CLEAN_SOURCE, + }; +} + +function factorIds(report: ReturnType): string[] { + return report.factors.map((factor) => factor.id); +} + +describe("secure configuration", () => { + it("scores at the top of the range with no penalties", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.score).toBe(100); + expect(report.riskLevel).toBe("low"); + expect(report.warnings).toEqual([]); + }); + + it("reports the wallet type and assessed capabilities", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.walletType).toBe(WalletType.FREIGHTER); + expect(report.capabilities).toContainEqual({ id: "transaction.sign", supported: true }); + }); + + it("records a secure origin as an informational factor", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(factorIds(report)).toContain("origin.secure"); + }); + + it("marks vulnerability data as available", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.vulnerabilityDataAvailable).toBe(true); + expect(report.matchedVulnerabilities).toEqual([]); + }); + + it("does not claim a clean source proves safety", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + const factor = report.factors.find((entry) => entry.id === "vulnerability.none.known"); + + expect(factor?.summary).toContain("not proof of safety"); + }); + + it("is not flagged as high risk", () => { + expect(isHighRiskConnection(auditWalletSecurity(makeAdapter(), secureOptions()))).toBe(false); + }); +}); + +describe("degraded configuration", () => { + it("penalizes a missing signing capability as critical", () => { + const adapter = makeAdapter({ supported: ["account.read"] }); + const report = auditWalletSecurity(adapter, secureOptions()); + + expect(factorIds(report)).toContain("capability.transaction.sign.missing"); + expect(report.score).toBeLessThan(50); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("applies a smaller penalty for a missing optional capability", () => { + const adapter = makeAdapter({ + supported: ["account.read", "transaction.sign", "transaction.sign_soroban"], + }); + const report = auditWalletSecurity(adapter, secureOptions()); + + expect(report.score).toBe(95); + expect(report.riskLevel).toBe("low"); + }); + + it("penalizes an unavailable adapter", () => { + const report = auditWalletSecurity(makeAdapter({ available: false }), secureOptions()); + + expect(factorIds(report)).toContain("adapter.unavailable"); + expect(report.score).toBe(85); + }); + + it("treats an adapter whose availability check throws as unavailable", () => { + const adapter = makeAdapter(); + const throwing: WalletAdapter = { + ...adapter, + isAvailable: () => { + throw new Error("boom"); + }, + }; + + expect(factorIds(auditWalletSecurity(throwing, secureOptions()))).toContain( + "adapter.unavailable", + ); + }); + + it("penalizes a plain HTTP origin as high severity", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, origin: "http://app.example.com" }, + }); + + expect(factorIds(report)).toContain("origin.insecure"); + expect(report.score).toBe(70); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("treats HTTP on localhost as a development-only low risk", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, origin: "http://localhost:3000" }, + }); + + expect(factorIds(report)).toContain("origin.localhost"); + expect(report.score).toBe(95); + }); + + it("penalizes an unparseable origin", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, origin: "not a url" }, + }); + + expect(factorIds(report)).toContain("origin.unparseable"); + }); + + it("flags a connection that reports connected with no public key", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, connected: true, publicKey: null }, + }); + + expect(factorIds(report)).toContain("connection.unauthenticated"); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("continues the assessment when capabilities cannot be read", () => { + const adapter = makeAdapter({ + getCapabilities: () => { + throw new Error("adapter exploded"); + }, + }); + const report = auditWalletSecurity(adapter, secureOptions()); + + expect(factorIds(report)).toContain("capabilities.unreadable"); + expect(report.capabilities).toEqual([]); + }); + + it("notes when capability values were inferred rather than reported", () => { + // No getCapabilities — the shared helper falls back to its static table. + const adapter = makeAdapter(); + const withoutCapabilities: WalletAdapter = { ...adapter }; + delete (withoutCapabilities as { getCapabilities?: unknown }).getCapabilities; + + const report = auditWalletSecurity(withoutCapabilities, secureOptions()); + expect(factorIds(report)).toContain("capabilities.inferred"); + }); +}); + +describe("vulnerable configuration", () => { + const vulnerableSource: VulnerabilitySource = { + name: "test-advisories", + knownWallets: [WalletType.FREIGHTER], + updatedAt: NOW, + vulnerabilities: [ + { + id: "CVE-2026-0001", + walletType: WalletType.FREIGHTER, + severity: "critical", + summary: "Signature request origin is not validated.", + affectedVersions: ["1.2.3"], + }, + ], + }; + + it("matches a vulnerability affecting the connected version", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: vulnerableSource, + }); + + expect(report.matchedVulnerabilities.map((v) => v.id)).toEqual(["CVE-2026-0001"]); + expect(report.score).toBe(50); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("surfaces the vulnerability as a warning", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: vulnerableSource, + }); + + expect(report.warnings.some((w) => w.includes("CVE-2026-0001"))).toBe(true); + }); + + it("does not match a version outside the affected range", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, adapterVersion: "9.9.9" }, + vulnerabilitySource: vulnerableSource, + }); + + expect(report.matchedVulnerabilities).toEqual([]); + expect(report.score).toBe(100); + }); + + it("does not match a vulnerability for a different wallet", () => { + const report = auditWalletSecurity(makeAdapter({ walletType: WalletType.XBULL }), { + ...secureOptions(), + vulnerabilitySource: { + ...vulnerableSource, + knownWallets: [WalletType.FREIGHTER, WalletType.XBULL], + }, + }); + + expect(report.matchedVulnerabilities).toEqual([]); + }); + + it("treats an advisory with no version constraint as applying to all versions", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { + ...vulnerableSource, + vulnerabilities: [ + { + id: "CVE-2026-0002", + walletType: WalletType.FREIGHTER, + severity: "high", + summary: "Affects every released version.", + }, + ], + }, + }); + + expect(report.matchedVulnerabilities.map((v) => v.id)).toEqual(["CVE-2026-0002"]); + }); + + it("accumulates penalties across multiple vulnerabilities", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { + ...vulnerableSource, + vulnerabilities: [ + { + id: "A", + walletType: WalletType.FREIGHTER, + severity: "high", + summary: "First issue.", + }, + { + id: "B", + walletType: WalletType.FREIGHTER, + severity: "medium", + summary: "Second issue.", + }, + ], + }, + }); + + expect(report.matchedVulnerabilities).toHaveLength(2); + expect(report.score).toBe(55); + }); + + it("never scores below zero", () => { + const many = Array.from({ length: 10 }, (_, index) => ({ + id: `CVE-${index}`, + walletType: WalletType.FREIGHTER, + severity: "critical" as const, + summary: "Severe issue.", + })); + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { ...vulnerableSource, vulnerabilities: many }, + }); + + expect(report.score).toBe(0); + expect(report.riskLevel).toBe("high"); + }); +}); + +describe("unknown configuration", () => { + it("does not treat a missing vulnerability source as safe", () => { + const options = secureOptions(); + delete options.vulnerabilitySource; + const report = auditWalletSecurity(makeAdapter(), options); + + expect(report.vulnerabilityDataAvailable).toBe(false); + expect(factorIds(report)).toContain("vulnerability.source.absent"); + expect(report.score).toBeLessThan(100); + }); + + it("marks a wallet the source has not been checked against as unknown", () => { + const report = auditWalletSecurity(makeAdapter({ walletType: WalletType.RABET }), { + ...secureOptions(), + vulnerabilitySource: CLEAN_SOURCE, + }); + + expect(report.vulnerabilityDataAvailable).toBe(false); + expect(factorIds(report)).toContain("vulnerability.wallet.uncovered"); + }); + + it("penalizes an unknown adapter version against version-scoped advisories", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, adapterVersion: undefined }, + vulnerabilitySource: { + ...CLEAN_SOURCE, + vulnerabilities: [ + { + id: "CVE-2026-0003", + walletType: WalletType.XBULL, + severity: "high", + summary: "Other wallet issue.", + affectedVersions: ["0.1.0"], + }, + ], + }, + }); + + expect(factorIds(report)).toContain("vulnerability.version.unknown"); + }); + + it("penalizes a source with no update timestamp", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { name: "undated", vulnerabilities: [], knownWallets: [WalletType.FREIGHTER] }, + }); + + expect(factorIds(report)).toContain("vulnerability.source.undated"); + }); + + it("penalizes a stale source", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { ...CLEAN_SOURCE, updatedAt: NOW - 40 * DAY }, + }); + + expect(factorIds(report)).toContain("vulnerability.source.stale"); + }); + + it("respects a custom staleness window", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { ...CLEAN_SOURCE, updatedAt: NOW - 2 * DAY }, + maxSourceAgeMs: DAY, + }); + + expect(factorIds(report)).toContain("vulnerability.source.stale"); + }); + + it("records that origin could not be evaluated when none is supplied", () => { + const report = auditWalletSecurity(makeAdapter(), { + now: NOW, + vulnerabilitySource: CLEAN_SOURCE, + }); + + expect(factorIds(report)).toContain("origin.unavailable"); + }); +}); + +describe("report structure", () => { + it("is deterministic across repeated audits of identical input", () => { + const first = auditWalletSecurity(makeAdapter(), secureOptions()); + const second = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(first.score).toBe(second.score); + expect(factorIds(first)).toEqual(factorIds(second)); + }); + + it("keeps the score within 0 and 100", () => { + const report = auditWalletSecurity(makeAdapter({ supported: [] }), { + now: NOW, + connection: { connected: true, publicKey: null, origin: "http://evil.example.com" }, + }); + + expect(report.score).toBeGreaterThanOrEqual(0); + expect(report.score).toBeLessThanOrEqual(100); + }); + + it("exposes the individual factors that produced the score", () => { + const report = auditWalletSecurity(makeAdapter({ supported: ["account.read"] }), secureOptions()); + const penalties = report.factors.filter((factor) => factor.scoreDelta < 0); + + expect(penalties.length).toBeGreaterThan(0); + expect(report.score).toBe( + 100 + report.factors.reduce((total, factor) => total + factor.scoreDelta, 0), + ); + }); + + it("excludes informational factors from warnings", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.factors.some((factor) => factor.severity === "info")).toBe(true); + expect(report.warnings).toEqual([]); + }); + + it("marks every score delta as a non-positive penalty", () => { + const report = auditWalletSecurity(makeAdapter({ supported: [] }), secureOptions()); + + expect(report.factors.every((factor) => factor.scoreDelta <= 0)).toBe(true); + }); + + it("maps scores onto risk bands", () => { + const high = auditWalletSecurity(makeAdapter({ supported: [] }), { + now: NOW, + connection: { connected: true, publicKey: null, origin: "http://evil.example.com" }, + }); + + expect(high.riskLevel).toBe("high"); + expect(auditWalletSecurity(makeAdapter(), secureOptions()).riskLevel).toBe("low"); + }); +}); diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 41fe10a..2765775 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -651,3 +651,16 @@ export type { HardwareWalletDevice, HardwareWalletCapabilities, } from "./hardwareWallet"; + +export { auditWalletSecurity, isHighRiskConnection } from "./securityAudit"; +export type { + RiskSeverity, + RiskConfidence, + RiskFactor, + WalletVulnerability, + VulnerabilitySource, + WalletConnectionContext, + WalletSecurityAuditOptions, + RiskLevel, + WalletSecurityReport, +} from "./securityAudit"; diff --git a/src/wallet/securityAudit.ts b/src/wallet/securityAudit.ts new file mode 100644 index 0000000..eb99335 --- /dev/null +++ b/src/wallet/securityAudit.ts @@ -0,0 +1,458 @@ +import { getWalletCapabilities, WALLET_CAPABILITY_IDS } from "./capabilities"; +import type { WalletAdapter, WalletCapabilities, WalletCapabilityId, WalletType } from "./types"; + +// ─── Types ─── + +/** Severity of a single risk factor. */ +export type RiskSeverity = "info" | "low" | "medium" | "high" | "critical"; + +/** Confidence in the evidence behind a factor. */ +export type RiskConfidence = "confirmed" | "reported" | "unknown"; + +/** + * One contributing element of an assessment. + * + * `scoreDelta` is the penalty this factor applied to the score. A factor with a + * delta of 0 is informational and did not affect the score. + */ +export interface RiskFactor { + id: string; + severity: RiskSeverity; + confidence: RiskConfidence; + /** What was observed. */ + summary: string; + /** Penalty applied to the base score. Always <= 0. */ + scoreDelta: number; +} + +/** A known vulnerability affecting a wallet, supplied by the caller. */ +export interface WalletVulnerability { + id: string; + walletType: WalletType; + severity: RiskSeverity; + summary: string; + /** Adapter versions affected. When omitted, all versions are treated as affected. */ + affectedVersions?: readonly string[]; +} + +/** + * A source of vulnerability information. + * + * `knownWallets` declares which wallets the source has actually been checked + * against. A wallet absent from that list is reported as unknown rather than + * clean — see {@link auditWalletSecurity}. + */ +export interface VulnerabilitySource { + name: string; + vulnerabilities: readonly WalletVulnerability[]; + knownWallets?: readonly WalletType[]; + /** Epoch milliseconds the source data was produced. */ + updatedAt?: number; +} + +/** Details of the live connection being assessed. */ +export interface WalletConnectionContext { + connected?: boolean; + publicKey?: string | null; + /** Page origin the connection was established from, when observable. */ + origin?: string; + /** Adapter version string, when the adapter reports one. */ + adapterVersion?: string; +} + +export interface WalletSecurityAuditOptions { + connection?: WalletConnectionContext; + vulnerabilitySource?: VulnerabilitySource; + /** Epoch milliseconds treated as now, for staleness checks. */ + now?: number; + /** Age beyond which a vulnerability source is considered stale. Defaults to 30 days. */ + maxSourceAgeMs?: number; +} + +export type RiskLevel = "low" | "moderate" | "elevated" | "high"; + +export interface WalletSecurityReport { + walletType: WalletType; + /** + * Deterministic 0–100 rating derived from the factors below. + * + * This score summarizes only what was observable at audit time. It is not a + * guarantee of safety — read {@link WalletSecurityReport.factors}. + */ + score: number; + riskLevel: RiskLevel; + /** Every factor considered, including those that applied no penalty. */ + factors: readonly RiskFactor[]; + /** Human-readable warnings for factors needing attention. */ + warnings: readonly string[]; + /** Capabilities the adapter reported, as assessed. */ + capabilities: readonly { id: WalletCapabilityId; supported: boolean }[]; + /** True when vulnerability data for this wallet could not be established. */ + vulnerabilityDataAvailable: boolean; + /** Vulnerabilities matched against this wallet. */ + matchedVulnerabilities: readonly WalletVulnerability[]; +} + +// ─── Scoring constants ─── +// +// Penalties are fixed so that the same inputs always produce the same score. + +const BASE_SCORE = 100; + +const SEVERITY_PENALTY: Record = { + info: 0, + low: 5, + medium: 15, + high: 30, + critical: 50, +}; + +const DEFAULT_MAX_SOURCE_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +/** Capabilities whose absence weakens the security posture. */ +const SECURITY_RELEVANT_CAPABILITIES: readonly { + id: WalletCapabilityId; + severity: RiskSeverity; + label: string; +}[] = [ + { id: WALLET_CAPABILITY_IDS.transactionSign, severity: "critical", label: "transaction signing" }, + { + id: WALLET_CAPABILITY_IDS.transactionSignMultisig, + severity: "low", + label: "multi-signature signing", + }, + { + id: WALLET_CAPABILITY_IDS.transactionSignSoroban, + severity: "low", + label: "Soroban transaction signing", + }, +]; + +// ─── Origin evaluation ─── + +function isLocalHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; +} + +/** + * Assess the connection origin. + * + * Returns `undefined` when no origin was supplied — an unobservable origin is + * reported separately rather than silently passing. + */ +function evaluateOrigin(origin: string | undefined): RiskFactor | undefined { + if (origin === undefined) return undefined; + + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return { + id: "origin.unparseable", + severity: "medium", + confidence: "confirmed", + summary: `Connection origin "${origin}" is not a valid URL and could not be evaluated.`, + scoreDelta: -SEVERITY_PENALTY.medium, + }; + } + + if (parsed.protocol === "https:") { + return { + id: "origin.secure", + severity: "info", + confidence: "confirmed", + summary: `Connection origin ${parsed.origin} uses HTTPS.`, + scoreDelta: 0, + }; + } + + if (parsed.protocol === "http:" && isLocalHost(parsed.hostname)) { + return { + id: "origin.localhost", + severity: "low", + confidence: "confirmed", + summary: `Connection origin ${parsed.origin} is plain HTTP on localhost — acceptable for development, not production.`, + scoreDelta: -SEVERITY_PENALTY.low, + }; + } + + return { + id: "origin.insecure", + severity: "high", + confidence: "confirmed", + summary: `Connection origin ${parsed.origin} does not use HTTPS — traffic can be intercepted or modified.`, + scoreDelta: -SEVERITY_PENALTY.high, + }; +} + +// ─── Vulnerability matching ─── + +function versionAffected( + vulnerability: WalletVulnerability, + adapterVersion: string | undefined, +): boolean { + if (!vulnerability.affectedVersions || vulnerability.affectedVersions.length === 0) return true; + if (adapterVersion === undefined) return true; + return vulnerability.affectedVersions.includes(adapterVersion); +} + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} + +function toRiskLevel(score: number): RiskLevel { + if (score >= 85) return "low"; + if (score >= 65) return "moderate"; + if (score >= 40) return "elevated"; + return "high"; +} + +// ─── Audit ─── + +/** + * Produce a structured security assessment of a wallet adapter and connection. + * + * The report exposes every factor that produced the score so callers can act on + * the underlying evidence rather than the number alone. + * + * Important limits — the score is not a safety guarantee: + * - It reflects only what was observable at audit time: declared adapter + * capabilities, the supplied connection context, and whatever vulnerability + * data the caller passed in. + * - Capabilities are self-declared by the adapter and are not verified here. + * - With no `vulnerabilitySource`, or one that has not been checked against this + * wallet, vulnerability status is *unknown*. Unknown status is penalized, never + * treated as clean, and `vulnerabilityDataAvailable` is false. + * + * @param adapter - The wallet adapter to assess. + * @param options - Connection context and vulnerability source. + * @returns A structured report. This function does not throw. + */ +export function auditWalletSecurity( + adapter: WalletAdapter, + options: WalletSecurityAuditOptions = {}, +): WalletSecurityReport { + const factors: RiskFactor[] = []; + const now = options.now ?? Date.now(); + + let capabilities: WalletCapabilities; + try { + capabilities = getWalletCapabilities(adapter); + } catch { + capabilities = { + walletType: adapter.walletType, + capabilities: [], + supports: () => false, + }; + factors.push({ + id: "capabilities.unreadable", + severity: "medium", + confidence: "unknown", + summary: "Adapter capabilities could not be read; assessment proceeded without them.", + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } + + // ─── Availability ─── + let available: boolean; + try { + available = adapter.isAvailable(); + } catch { + available = false; + } + if (!available) { + factors.push({ + id: "adapter.unavailable", + severity: "medium", + confidence: "confirmed", + summary: "Wallet adapter reports it is not available in this environment.", + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } + + // ─── Capabilities ─── + for (const expected of SECURITY_RELEVANT_CAPABILITIES) { + const supported = capabilities.capabilities.some( + (capability) => capability.id === expected.id && capability.supported, + ); + if (supported) { + factors.push({ + id: `capability.${expected.id}.supported`, + severity: "info", + confidence: "confirmed", + summary: `Adapter declares support for ${expected.label}.`, + scoreDelta: 0, + }); + } else { + factors.push({ + id: `capability.${expected.id}.missing`, + severity: expected.severity, + confidence: "confirmed", + summary: `Adapter does not declare support for ${expected.label}.`, + scoreDelta: -SEVERITY_PENALTY[expected.severity], + }); + } + } + + // A capability answered from the fallback table was inferred, not reported. + const inferred = capabilities.capabilities.filter( + (capability) => capability.source === "fallback", + ); + if (inferred.length > 0) { + factors.push({ + id: "capabilities.inferred", + severity: "info", + confidence: "reported", + summary: `${inferred.length} capability value(s) were inferred from a static table rather than reported by the adapter.`, + scoreDelta: 0, + }); + } + + // ─── Connection state ─── + const connection = options.connection; + if (connection?.connected === true && !connection.publicKey) { + factors.push({ + id: "connection.unauthenticated", + severity: "high", + confidence: "confirmed", + summary: "Connection reports as connected but exposes no public key — authentication state is inconsistent.", + scoreDelta: -SEVERITY_PENALTY.high, + }); + } + + // ─── Origin ─── + const originFactor = evaluateOrigin(connection?.origin); + if (originFactor) { + factors.push(originFactor); + } else { + factors.push({ + id: "origin.unavailable", + severity: "info", + confidence: "unknown", + summary: "No connection origin was supplied, so origin could not be evaluated.", + scoreDelta: 0, + }); + } + + // ─── Vulnerabilities ─── + const source = options.vulnerabilitySource; + const matched: WalletVulnerability[] = []; + let vulnerabilityDataAvailable = false; + + if (!source) { + factors.push({ + id: "vulnerability.source.absent", + severity: "medium", + confidence: "unknown", + summary: + "No vulnerability source was configured. Vulnerability status is unknown and is not treated as clean.", + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } else { + const covered = + source.knownWallets === undefined || source.knownWallets.includes(adapter.walletType); + + if (!covered) { + factors.push({ + id: "vulnerability.wallet.uncovered", + severity: "medium", + confidence: "unknown", + summary: `Vulnerability source "${source.name}" has not been checked against ${adapter.walletType}; its status is unknown.`, + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } else { + vulnerabilityDataAvailable = true; + + for (const vulnerability of source.vulnerabilities) { + if (vulnerability.walletType !== adapter.walletType) continue; + if (!versionAffected(vulnerability, connection?.adapterVersion)) continue; + matched.push(vulnerability); + factors.push({ + id: `vulnerability.${vulnerability.id}`, + severity: vulnerability.severity, + confidence: "reported", + summary: `Known vulnerability ${vulnerability.id}: ${vulnerability.summary}`, + scoreDelta: -SEVERITY_PENALTY[vulnerability.severity], + }); + } + + if (matched.length === 0) { + factors.push({ + id: "vulnerability.none.known", + severity: "info", + confidence: "reported", + summary: `No known vulnerabilities for ${adapter.walletType} in source "${source.name}". Absence of a report is not proof of safety.`, + scoreDelta: 0, + }); + } + + // An unversioned adapter cannot be excluded from version-scoped advisories. + if (connection?.adapterVersion === undefined && source.vulnerabilities.length > 0) { + factors.push({ + id: "vulnerability.version.unknown", + severity: "low", + confidence: "unknown", + summary: + "Adapter version is unknown, so version-scoped advisories were treated as applicable.", + scoreDelta: -SEVERITY_PENALTY.low, + }); + } + } + + const age = source.updatedAt === undefined ? undefined : now - source.updatedAt; + const maxAge = options.maxSourceAgeMs ?? DEFAULT_MAX_SOURCE_AGE_MS; + if (source.updatedAt === undefined) { + factors.push({ + id: "vulnerability.source.undated", + severity: "low", + confidence: "unknown", + summary: `Vulnerability source "${source.name}" carries no update timestamp; its freshness is unknown.`, + scoreDelta: -SEVERITY_PENALTY.low, + }); + } else if (age !== undefined && age > maxAge) { + factors.push({ + id: "vulnerability.source.stale", + severity: "low", + confidence: "confirmed", + summary: `Vulnerability source "${source.name}" is stale — last updated ${Math.floor(age / 86_400_000)} day(s) ago.`, + scoreDelta: -SEVERITY_PENALTY.low, + }); + } + } + + const score = clampScore( + factors.reduce((total, factor) => total + factor.scoreDelta, BASE_SCORE), + ); + + const warnings = factors + .filter((factor) => factor.severity !== "info") + .map((factor) => factor.summary); + + return { + walletType: adapter.walletType, + score, + riskLevel: toRiskLevel(score), + factors, + warnings, + capabilities: capabilities.capabilities.map((capability) => ({ + id: capability.id, + supported: capability.supported, + })), + vulnerabilityDataAvailable, + matchedVulnerabilities: matched, + }; +} + +/** + * Whether a report should block or warn a user before proceeding. + * + * True when the score falls into the elevated or high band, or any factor is + * high or critical severity. + */ +export function isHighRiskConnection(report: WalletSecurityReport): boolean { + return ( + report.riskLevel === "elevated" || + report.riskLevel === "high" || + report.factors.some((factor) => factor.severity === "high" || factor.severity === "critical") + ); +} From 653548112b1354731c88ae609a5354a3a2e063a1 Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:56:34 +0100 Subject: [PATCH 5/5] fix(soroban): restore export statement lost in merge resolution --- src/soroban/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/soroban/index.ts b/src/soroban/index.ts index ba9748c..a1aa7d2 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -515,6 +515,8 @@ export type { SnapshotIntegrityReport, SnapshotQuery, } from "./contractStateHistory"; + +export { MultiSigContractExecution, createMultiSigContractExecution, } from "./multiSigExecution";