diff --git a/package-lock.json b/package-lock.json index 3d596a9..c0ac3fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "fake-indexeddb": "^6.2.5", + "fast-check": "^4.9.0", "graphql": "^16.14.0", "jsdom": "^29.1.1", "react": "^19.2.7", @@ -3418,6 +3419,29 @@ "node": ">=18" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "dev": true, @@ -4467,6 +4491,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", diff --git a/package.json b/package.json index 9d3f8ac..25eacad 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", + "test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/property-roundtrip.test.ts test/property-address.test.ts test/property-deadline.test.ts test/property-invoice-params.test.ts test/property-client-invariants.test.ts", "test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/resilience.test.ts", "test": "vitest run test/client.test.ts test/multiTenant.test.ts", "test": "vitest run test/client.test.ts test/profiler.test.ts", @@ -67,6 +68,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "fake-indexeddb": "^6.2.5", + "fast-check": "^4.9.0", "graphql": "^16.14.0", "jsdom": "^29.1.1", "react": "^19.2.7", diff --git a/test/property-address.test.ts b/test/property-address.test.ts new file mode 100644 index 0000000..2e52e50 --- /dev/null +++ b/test/property-address.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { isValidStellarAddress } from "../src/utils.js"; + +const VALID_ADDRESSES = Array.from({ length: 50 }, () => Keypair.random().publicKey()); + +describe("isValidStellarAddress (property-based)", () => { + it("returns true for all randomly generated Stellar keypairs", () => { + fc.assert( + fc.property(fc.constantFrom(...VALID_ADDRESSES), (address) => { + expect(isValidStellarAddress(address)).toBe(true); + }), + { numRuns: 500 }, + ); + }); + + it("returns false for arbitrary non-G-prefixed strings", () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 100 }), + (s) => { + fc.pre(!s.startsWith("G")); + expect(isValidStellarAddress(s)).toBe(false); + }, + ), + { numRuns: 500 }, + ); + }); + + it("returns false for strings too short to be valid", () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 55 }), + (s) => { + fc.pre(s.length < 56); + expect(isValidStellarAddress(s)).toBe(false); + }, + ), + { numRuns: 500 }, + ); + }); + + it("returns false for G-prefixed strings with invalid base32 chars", () => { + fc.assert( + fc.property( + fc.integer({ min: 56, max: 100 }), + fc.constant("0123456789ABCDEFGHIJKLMNOPQRSTU.VWXYZ"), + (len, alphabet) => { + fc.pre(len > 1); + const invalid = + "G" + + Array.from( + { length: len - 1 }, + () => alphabet[Math.floor(Math.random() * alphabet.length)], + ).join(""); + const result = isValidStellarAddress(invalid); + expect(result).toBe(false); + }, + ), + { numRuns: 500 }, + ); + }); + + it("returns false for empty string", () => { + expect(isValidStellarAddress("")).toBe(false); + }); + + it("returns false for 'null' and 'undefined' as strings", () => { + expect(isValidStellarAddress("null")).toBe(false); + expect(isValidStellarAddress("undefined")).toBe(false); + }); + + it("returns false for strings containing non-ASCII", () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 50 }), + fc.integer({ min: 0x80, max: 0x10ffff }), + (prefix, codePoint) => { + const addr = prefix + String.fromCodePoint(codePoint); + expect(isValidStellarAddress(addr)).toBe(false); + }, + ), + { numRuns: 500 }, + ); + }); + + it("valid addresses always start with 'G'", () => { + fc.assert( + fc.property(fc.constantFrom(...VALID_ADDRESSES), (address) => { + expect(address.startsWith("G")).toBe(true); + }), + { numRuns: 500 }, + ); + }); + + it("valid addresses are exactly 56 characters", () => { + fc.assert( + fc.property(fc.constantFrom(...VALID_ADDRESSES), (address) => { + expect(address.length).toBe(56); + }), + { numRuns: 500 }, + ); + }); +}); diff --git a/test/property-client-invariants.test.ts b/test/property-client-invariants.test.ts new file mode 100644 index 0000000..d074f9b --- /dev/null +++ b/test/property-client-invariants.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + formatAmount, + parseAmount, + isValidStellarAddress, + deadlineFromDays, + isExpired, + truncateAddress, +} from "../src/utils.js"; +import type { InvoiceStatus } from "../src/types.js"; + +const STATUSES: InvoiceStatus[] = ["Pending", "Released", "Refunded", "Cancelled"]; +const VALID_ADDRESSES = Array.from({ length: 50 }, () => Keypair.random().publicKey()); + +describe("StellarSplitClient sequential call invariants (property-based)", () => { + it("parseAmount is inverse of formatAmount for all valid stroops", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 50_000_000_000_000n }), + (stroops) => { + const formatted = formatAmount(stroops); + const parsed = parseAmount(formatted); + expect(parsed).toBe(stroops); + }, + ), + { numRuns: 500 }, + ); + }); + + it("formatAmount(parseAmount(s)) is idempotent for 7-decimal strings", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 1_000_000_000_000n }), + (stroops) => { + const formatted = formatAmount(stroops); + const reformatted = formatAmount(parseAmount(formatted)); + expect(reformatted).toBe(formatted); + }, + ), + { numRuns: 500 }, + ); + }); + + it("isValidStellarAddress is consistent across repeated calls", () => { + fc.assert( + fc.property(fc.constantFrom(...VALID_ADDRESSES), (address) => { + const first = isValidStellarAddress(address); + const second = isValidStellarAddress(address); + expect(second).toBe(first); + }), + { numRuns: 500 }, + ); + }); + + it("truncateAddress preserves prefix and suffix", () => { + fc.assert( + fc.property( + fc.constantFrom(...VALID_ADDRESSES), + fc.integer({ min: 1, max: 10 }), + (address, chars) => { + const truncated = truncateAddress(address, chars); + expect(truncated.startsWith(address.slice(0, chars))).toBe(true); + expect(truncated.endsWith(address.slice(-chars))).toBe(true); + expect(truncated).toContain("..."); + }, + ), + { numRuns: 500 }, + ); + }); + + it("truncateAddress returns original string if too short", () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 10 }), + fc.integer({ min: 1, max: 10 }), + (short, chars) => { + fc.pre(short.length <= chars * 2 + 3); + expect(truncateAddress(short, chars)).toBe(short); + }, + ), + { numRuns: 500 }, + ); + }); + + it("deadlineFromDays is deterministic for same input", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 3650 }), + (days) => { + const first = deadlineFromDays(days); + const second = deadlineFromDays(days); + expect(second).toBe(first); + }, + ), + { numRuns: 500 }, + ); + }); + + it("isExpired is consistent: if deadline < now, always expired", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 3650 }), + (daysAgo) => { + const now = Math.floor(Date.now() / 1000); + const past = now - daysAgo * 86400; + expect(isExpired(past)).toBe(true); + }, + ), + { numRuns: 500 }, + ); + }); + + it("all invoice statuses are valid string literals", () => { + for (const status of STATUSES) { + expect(typeof status).toBe("string"); + expect(["Pending", "Released", "Refunded", "Cancelled"]).toContain(status); + } + }); + + it("formatAmount output length is consistent across inputs", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 10_000_000_000n }), + (stroops) => { + const formatted = formatAmount(stroops); + const dotIndex = formatted.indexOf("."); + expect(dotIndex).toBeGreaterThan(0); + expect(formatted.length - dotIndex - 1).toBe(7); + }, + ), + { numRuns: 500 }, + ); + }); + + it("parseAmount of negative-looking string returns negative bigint", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 1n, max: 1_000_000_000n }), + (positive) => { + const negativeStr = `-${positive}`; + const result = parseAmount(negativeStr); + expect(result).toBeLessThan(0n); + }, + ), + { numRuns: 500 }, + ); + }); +}); diff --git a/test/property-deadline.test.ts b/test/property-deadline.test.ts new file mode 100644 index 0000000..4eecc7b --- /dev/null +++ b/test/property-deadline.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { deadlineFromDays, isExpired } from "../src/utils.js"; + +describe("deadlineFromDays (property-based)", () => { + it("returns a timestamp in the future for positive day counts", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 36500 }), + (days) => { + const now = Math.floor(Date.now() / 1000); + const deadline = deadlineFromDays(days); + expect(deadline).toBeGreaterThan(now); + }, + ), + { numRuns: 500 }, + ); + }); + + it("returns approximately now + days * 86400", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 3650 }), + (days) => { + const now = Math.floor(Date.now() / 1000); + const deadline = deadlineFromDays(days); + const expected = now + days * 86400; + expect(Math.abs(deadline - expected)).toBeLessThanOrEqual(2); + }, + ), + { numRuns: 500 }, + ); + }); + + it("deadline is never less than now for positive days", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1000 }), + (days) => { + const deadline = deadlineFromDays(days); + expect(deadline).toBeGreaterThanOrEqual(Math.floor(Date.now() / 1000)); + }, + ), + { numRuns: 500 }, + ); + }); + + it("isExpired returns false for deadlines in the future", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 3650 }), + (days) => { + const deadline = deadlineFromDays(days); + expect(isExpired(deadline)).toBe(false); + }, + ), + { numRuns: 500 }, + ); + }); + + it("isExpired returns true for deadlines in the past", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 3650 }), + (days) => { + const now = Math.floor(Date.now() / 1000); + const pastDeadline = now - days * 86400; + expect(isExpired(pastDeadline)).toBe(true); + }, + ), + { numRuns: 500 }, + ); + }); + + it("larger day counts produce larger deadlines", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1000 }), + fc.integer({ min: 1001, max: 2000 }), + (small, large) => { + const deadlineSmall = deadlineFromDays(small); + const deadlineLarge = deadlineFromDays(large); + expect(deadlineLarge).toBeGreaterThan(deadlineSmall); + }, + ), + { numRuns: 500 }, + ); + }); + + it("deadline is an integer (no fractional seconds)", () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 36500 }), + (days) => { + const deadline = deadlineFromDays(days); + expect(Number.isInteger(deadline)).toBe(true); + }, + ), + { numRuns: 500 }, + ); + }); + + it("deadlineFromDays(0) returns approximately now", () => { + const now = Math.floor(Date.now() / 1000); + const deadline = deadlineFromDays(0); + expect(Math.abs(deadline - now)).toBeLessThanOrEqual(2); + }); +}); diff --git a/test/property-invoice-params.test.ts b/test/property-invoice-params.test.ts new file mode 100644 index 0000000..41ae30c --- /dev/null +++ b/test/property-invoice-params.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { Keypair } from "@stellar/stellar-sdk"; +import { isValidStellarAddress, deadlineFromDays } from "../src/utils.js"; +import type { CreateInvoiceParams, Recipient } from "../src/types.js"; + +const ADDRESS_POOL = Array.from({ length: 30 }, () => Keypair.random().publicKey()); + +const validAddress = () => fc.constantFrom(...ADDRESS_POOL); + +const validAmount = () => fc.bigInt({ min: 1n, max: 1_000_000_000_000n }); + +const validDeadline = () => + fc.integer({ min: 1, max: 3650 }).map((d) => deadlineFromDays(d)); + +const validRecipient = (): fc.Arbitrary => + fc.record({ address: validAddress(), amount: validAmount() }); + +describe("CreateInvoiceParams (property-based)", () => { + it("creator address is always a valid Stellar address", () => { + fc.assert( + fc.property(validAddress(), (creator) => { + expect(isValidStellarAddress(creator)).toBe(true); + }), + { numRuns: 500 }, + ); + }); + + it("all recipient addresses are valid Stellar addresses", () => { + fc.assert( + fc.property( + fc.array(validRecipient(), { minLength: 1, maxLength: 10 }), + (recipients) => { + for (const r of recipients) { + expect(isValidStellarAddress(r.address)).toBe(true); + } + }, + ), + { numRuns: 500 }, + ); + }); + + it("all recipient amounts are positive bigint", () => { + fc.assert( + fc.property( + fc.array(validRecipient(), { minLength: 1, maxLength: 10 }), + (recipients) => { + for (const r of recipients) { + expect(typeof r.amount).toBe("bigint"); + expect(r.amount).toBeGreaterThan(0n); + } + }, + ), + { numRuns: 500 }, + ); + }); + + it("token address is always a valid Stellar address", () => { + fc.assert( + fc.property(validAddress(), (token) => { + expect(isValidStellarAddress(token)).toBe(true); + }), + { numRuns: 500 }, + ); + }); + + it("deadline is always in the future", () => { + fc.assert( + fc.property(validDeadline(), (deadline) => { + const now = Math.floor(Date.now() / 1000); + expect(deadline).toBeGreaterThan(now); + }), + { numRuns: 500 }, + ); + }); + + it("recipients array is non-empty", () => { + fc.assert( + fc.property( + fc.array(validRecipient(), { minLength: 1, maxLength: 20 }), + (recipients) => { + expect(recipients.length).toBeGreaterThanOrEqual(1); + }, + ), + { numRuns: 500 }, + ); + }); + + it("no two recipients share the same address", () => { + fc.assert( + fc.property( + fc.uniqueArray( + fc.record({ address: validAddress(), amount: validAmount() }), + { + minLength: 1, + maxLength: 10, + comparator: (a: Recipient, b: Recipient) => a.address === b.address, + }, + ), + (recipients) => { + const addresses = recipients.map((r) => r.address); + const unique = new Set(addresses); + expect(unique.size).toBe(addresses.length); + }, + ), + { numRuns: 500 }, + ); + }); + + it("total funded amount fits in bigint range", () => { + fc.assert( + fc.property( + fc.array(validRecipient(), { minLength: 1, maxLength: 10 }), + (recipients) => { + const total = recipients.reduce((sum, r) => sum + r.amount, 0n); + expect(typeof total).toBe("bigint"); + expect(total).toBeGreaterThan(0n); + }, + ), + { numRuns: 500 }, + ); + }); + + it("CreateInvoiceParams structure has required fields", () => { + fc.assert( + fc.property( + validAddress(), + fc.array(validRecipient(), { minLength: 1, maxLength: 5 }), + validAddress(), + validDeadline(), + (creator, recipients, token, deadline) => { + const params: CreateInvoiceParams = { + creator, + recipients, + token, + deadline, + }; + expect(params).toHaveProperty("creator"); + expect(params).toHaveProperty("recipients"); + expect(params).toHaveProperty("token"); + expect(params).toHaveProperty("deadline"); + expect(params.recipients.length).toBeGreaterThanOrEqual(1); + }, + ), + { numRuns: 500 }, + ); + }); +}); diff --git a/test/property-roundtrip.test.ts b/test/property-roundtrip.test.ts new file mode 100644 index 0000000..799c4df --- /dev/null +++ b/test/property-roundtrip.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { formatAmount, parseAmount } from "../src/utils.js"; + +const STROOPS_PER_UNIT = 10_000_000n; + +describe("parseAmount / formatAmount roundtrip (property-based)", () => { + it("parseAmount(formatAmount(s)) === s for all non-negative bigint stroops", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 100_000_000_000_000n }), + (stroops) => { + const formatted = formatAmount(stroops); + const parsed = parseAmount(formatted); + expect(parsed).toBe(stroops); + }, + ), + { numRuns: 500 }, + ); + }); + + it("formatAmount always produces exactly 7 decimal places", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 10_000_000_000n }), + (stroops) => { + const formatted = formatAmount(stroops); + const parts = formatted.split("."); + expect(parts).toHaveLength(2); + expect(parts[1]).toHaveLength(7); + }, + ), + { numRuns: 500 }, + ); + }); + + it("formatAmount produces a valid number string", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 10_000_000_000n }), + (stroops) => { + const formatted = formatAmount(stroops); + expect(Number(formatted)).not.toBeNaN(); + expect(Number(formatted)).toBeGreaterThanOrEqual(0); + }, + ), + { numRuns: 500 }, + ); + }); + + it("parseAmount of formatAmount with trailing zeros roundtrips", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 1_000_000_000_000n }), + fc.integer({ min: 0, max: 6 }), + (stroops, extraZeros) => { + const formatted = formatAmount(stroops); + const withZeros = formatted + "0".repeat(extraZeros); + const parsed = parseAmount(withZeros); + expect(parsed).toBe(stroops); + }, + ), + { numRuns: 500 }, + ); + }); + + it("parseAmount of integer string equals stroops", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 1_000_000_000n }), + (whole) => { + const parsed = parseAmount(whole.toString()); + expect(parsed).toBe(whole * STROOPS_PER_UNIT); + }, + ), + { numRuns: 500 }, + ); + }); + + it("parseAmount of '0' always returns 0n", () => { + expect(parseAmount("0")).toBe(0n); + expect(parseAmount("0.0")).toBe(0n); + expect(parseAmount("0.0000000")).toBe(0n); + }); + + it("formatAmount(0n) is '0.0000000'", () => { + expect(formatAmount(0n)).toBe("0.0000000"); + }); + + it("formatAmount is monotonic for positive stroops", () => { + fc.assert( + fc.property( + fc.bigInt({ min: 1n, max: 50_000_000_000n }), + (stroops) => { + const a = formatAmount(stroops); + const b = formatAmount(stroops + 1n); + expect(Number(a)).toBeLessThan(Number(b)); + }, + ), + { numRuns: 500 }, + ); + }); +});