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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions __tests__/lib/custody/envelope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as stellarConfig from "@/lib/stellar/config"
import { buildEnvelope, computeEnvelopeHash, assertEnvelopeFresh, EnvelopeValidationError, getNetworkPassphrase } from "@/lib/custody/envelope"

vi.mock("@/lib/stellar/config")

const TESTNET_CONFIG = {
network: "testnet" as const,
horizonUrl: "https://horizon-testnet.stellar.org",
rpcUrl: "https://soroban-testnet.stellar.org",
assetCode: "CMOVE",
issuerPublicKey: "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H",
distributionPublicKey: "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA",
contractId: "",
mock: true,
}

const SOURCE_ACCOUNT = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H"
const DESTINATION = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA"

function baseInput(overrides: Partial<Parameters<typeof buildEnvelope>[0]> = {}) {
return {
sourceAccount: SOURCE_ACCOUNT,
sequence: "100",
minTime: new Date("2026-01-01T00:00:00.000Z"),
maxTime: new Date("2026-01-01T00:15:00.000Z"),
intent: {
category: "payout" as const,
operation: "distribution.payment",
params: { destination: DESTINATION, assetCode: "native", amount: "10.0000000" },
},
...overrides,
}
}

describe("buildEnvelope", () => {
beforeEach(() => {
vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG)
})

it("binds network, source, sequence, time bounds, memo, and intent", () => {
const envelope = buildEnvelope(baseInput())
expect(envelope.network).toBe("testnet")
expect(envelope.networkPassphrase).toBe(getNetworkPassphrase("testnet"))
expect(envelope.sourceAccount).toBe(SOURCE_ACCOUNT)
expect(envelope.sequence).toBe("100")
expect(envelope.memo).toEqual({ type: "none" })
expect(envelope.intent.operation).toBe("distribution.payment")
})

it("rejects an invalid source account", () => {
expect(() => buildEnvelope(baseInput({ sourceAccount: "not-a-key" }))).toThrow(EnvelopeValidationError)
})

it("rejects a non-numeric sequence", () => {
expect(() => buildEnvelope(baseInput({ sequence: "abc" }))).toThrow(EnvelopeValidationError)
})

it("rejects maxTime at or before minTime", () => {
expect(() =>
buildEnvelope(baseInput({ minTime: new Date("2026-01-01T00:15:00.000Z"), maxTime: new Date("2026-01-01T00:15:00.000Z") })),
).toThrow(EnvelopeValidationError)
})
})

describe("computeEnvelopeHash", () => {
beforeEach(() => {
vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG)
})

it("is deterministic for identical envelopes", () => {
const envelope = buildEnvelope(baseInput())
expect(computeEnvelopeHash(envelope)).toBe(computeEnvelopeHash(buildEnvelope(baseInput())))
})

it("changes when the intent (cross-intent) changes", () => {
const envelope = buildEnvelope(baseInput())
const differentIntent = buildEnvelope(
baseInput({ intent: { category: "payout", operation: "distribution.payment", params: { destination: DESTINATION, assetCode: "native", amount: "20.0000000" } } }),
)
expect(computeEnvelopeHash(envelope)).not.toBe(computeEnvelopeHash(differentIntent))
})

it("changes when the sequence changes", () => {
const envelope = buildEnvelope(baseInput())
const differentSequence = buildEnvelope(baseInput({ sequence: "101" }))
expect(computeEnvelopeHash(envelope)).not.toBe(computeEnvelopeHash(differentSequence))
})
})

describe("assertEnvelopeFresh", () => {
beforeEach(() => {
vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG)
})

const now = new Date("2026-01-01T00:05:00.000Z")

it("accepts a fresh envelope within its time bounds and above the watermark", () => {
const envelope = buildEnvelope(baseInput())
expect(() => assertEnvelopeFresh({ envelope, now, lastConsumedSequence: "99" })).not.toThrow()
})

it("rejects cross-network replay when the configured network differs", () => {
const envelope = buildEnvelope(baseInput())
vi.mocked(stellarConfig.getStellarConfig).mockReturnValue({ ...TESTNET_CONFIG, network: "mainnet" })
expect(() => assertEnvelopeFresh({ envelope, now })).toThrow(/Cross-network replay/)
})

it("rejects a stale/replayed sequence", () => {
const envelope = buildEnvelope(baseInput({ sequence: "100" }))
expect(() => assertEnvelopeFresh({ envelope, now, lastConsumedSequence: "100" })).toThrow(/Stale sequence rejected/)
expect(() => assertEnvelopeFresh({ envelope, now, lastConsumedSequence: "150" })).toThrow(/Stale sequence rejected/)
})

it("rejects an expired envelope (past maxTime) - replay-by-delay fails", () => {
const envelope = buildEnvelope(baseInput())
expect(() => assertEnvelopeFresh({ envelope, now: new Date("2026-01-01T01:00:00.000Z") })).toThrow(/expired/)
})

it("rejects an envelope not yet valid (before minTime)", () => {
const envelope = buildEnvelope(baseInput())
expect(() => assertEnvelopeFresh({ envelope, now: new Date("2025-12-31T23:00:00.000Z") })).toThrow(/not yet valid/)
})
})
151 changes: 151 additions & 0 deletions __tests__/lib/custody/policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, it, expect } from "vitest"
import {
DEFAULT_THRESHOLD_POLICIES,
getThresholdPolicy,
validateSignerSetInvariants,
assertPayoutWithinPolicy,
SignerSetInvariantError,
PolicyViolationError,
} from "@/lib/custody/policy"
import type { SignerDescriptor } from "@/lib/custody/types"

const KEY_A = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H"
const KEY_B = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA"
const KEY_C = "GBRLHRADGGA2RPHJ3AVHOTIT3GENGHQETXTHOHY4IUJJLI26KHWQBD6U"

describe("threshold matrices", () => {
it("defines a policy for every operation category with a sane threshold/signer floor", () => {
for (const category of Object.keys(DEFAULT_THRESHOLD_POLICIES) as Array<keyof typeof DEFAULT_THRESHOLD_POLICIES>) {
const policy = getThresholdPolicy(category)
expect(policy.minThreshold).toBeGreaterThan(0)
expect(policy.minSigners).toBeGreaterThanOrEqual(policy.minThreshold)
expect(policy.eligibleRoles.length).toBeGreaterThan(0)
}
})

it("requires issuance to be at least 2-of-N (mirrors issuer cold multisig)", () => {
expect(getThresholdPolicy("issuance").minThreshold).toBeGreaterThanOrEqual(2)
})

it("requires payout to enforce a destination allowlist", () => {
expect(getThresholdPolicy("payout").requireDestinationAllowlist).toBe(true)
})

it("requires rotation to span at least 2 distinct signer roles", () => {
expect(getThresholdPolicy("rotation").minDistinctRoles).toBeGreaterThanOrEqual(2)
})
})

function signers(overrides: Partial<SignerDescriptor>[] = []): SignerDescriptor[] {
const base: SignerDescriptor[] = [
{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 },
{ signerId: "issuer-2", role: "issuer", publicKey: KEY_B, weight: 1 },
{ signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 },
]
return overrides.length ? (overrides as SignerDescriptor[]) : base
}

describe("validateSignerSetInvariants", () => {
it("accepts a valid 2-of-3 issuance signer set", () => {
expect(() => validateSignerSetInvariants({ category: "issuance", signers: signers(), threshold: 2 })).not.toThrow()
})

it("rejects a threshold that exceeds total signer weight (permanent lockout)", () => {
expect(() => validateSignerSetInvariants({ category: "issuance", signers: signers(), threshold: 10 })).toThrow(
SignerSetInvariantError,
)
})

it("rejects duplicate signerId", () => {
const dup = [
{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 },
{ signerId: "issuer-1", role: "issuer", publicKey: KEY_B, weight: 1 },
{ signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 },
] as SignerDescriptor[]
expect(() => validateSignerSetInvariants({ category: "issuance", signers: dup, threshold: 2 })).toThrow(/Duplicate signerId/)
})

it("rejects duplicate signer public keys", () => {
const dup = [
{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 },
{ signerId: "issuer-2", role: "issuer", publicKey: KEY_A, weight: 1 },
{ signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 },
] as SignerDescriptor[]
expect(() => validateSignerSetInvariants({ category: "issuance", signers: dup, threshold: 2 })).toThrow(
/Duplicate signer public key/,
)
})

it("rejects too few signers for the category", () => {
const tooFew = [{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 }] as SignerDescriptor[]
expect(() => validateSignerSetInvariants({ category: "issuance", signers: tooFew, threshold: 1 })).toThrow(
/requires at least 3 signers/,
)
})

it("rejects a threshold below the category minimum", () => {
expect(() => validateSignerSetInvariants({ category: "issuance", signers: signers(), threshold: 1 })).toThrow(
/requires a threshold of at least/,
)
})

it("rejects insufficient distinct roles for rotation", () => {
const sameRole = [
{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 },
{ signerId: "issuer-2", role: "issuer", publicKey: KEY_B, weight: 1 },
{ signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 },
] as SignerDescriptor[]
expect(() => validateSignerSetInvariants({ category: "rotation", signers: sameRole, threshold: 2 })).toThrow(
/distinct role/,
)
})

it("rejects a non-positive-integer weight", () => {
const badWeight = [
{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 0 },
{ signerId: "issuer-2", role: "issuer", publicKey: KEY_B, weight: 1 },
{ signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 },
] as SignerDescriptor[]
expect(() => validateSignerSetInvariants({ category: "issuance", signers: badWeight, threshold: 1 })).toThrow(
/positive integer/,
)
})
})

describe("assertPayoutWithinPolicy", () => {
const allowedDestinations = [KEY_B]

it("accepts a payout within limits to an allowlisted destination", () => {
expect(() =>
assertPayoutWithinPolicy({ amount: "1000", destination: KEY_B, allowedDestinations, maxAmount: "5000", dailyLimit: "10000" }),
).not.toThrow()
})

it("rejects a destination not on the allowlist", () => {
expect(() =>
assertPayoutWithinPolicy({ amount: "1000", destination: KEY_C, allowedDestinations, maxAmount: "5000" }),
).toThrow(PolicyViolationError)
})

it("rejects an amount over the per-operation limit", () => {
expect(() =>
assertPayoutWithinPolicy({ amount: "6000", destination: KEY_B, allowedDestinations, maxAmount: "5000" }),
).toThrow(/exceeds per-operation limit/)
})

it("rejects an amount that would exceed the daily limit", () => {
expect(() =>
assertPayoutWithinPolicy({
amount: "3000",
destination: KEY_B,
allowedDestinations,
dailyTotalSoFar: "8000",
dailyLimit: "10000",
}),
).toThrow(/exceed daily limit/)
})

it("rejects a non-positive amount", () => {
expect(() => assertPayoutWithinPolicy({ amount: "0", destination: KEY_B, allowedDestinations })).toThrow(/must be positive/)
})
})
Loading
Loading