diff --git a/examples/verify-anchored-payout.ts b/examples/verify-anchored-payout.ts new file mode 100644 index 0000000..cdd8483 --- /dev/null +++ b/examples/verify-anchored-payout.ts @@ -0,0 +1,48 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { + anchorFundablePayout, + verifyAnchoredPayoutReceipt, + type FundablePayoutAuditRecord, +} from "../src/index.js"; + +async function main() { + console.log("=== Nirium Audit Trail Anchor for Fundable Payouts ===\n"); + + const payer = Keypair.random(); + const recipient = Keypair.random(); + + const samplePayout: FundablePayoutAuditRecord = { + protocol: "fundable", + chain: "stellar", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CA3D5KRYMCMCZVAC7OHQHG2CAUAQ77PDRWNXD7BOD7X62F26T4ZMY54K", + operation: "create_flow", + txHash: "4b7b3b9b8c0a1e2f3d4c5b6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c", + sender: payer.publicKey(), + recipient: recipient.publicKey(), + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + amountOrRate: "5000000", + startTime: Math.floor(Date.now() / 1000), + timestamp: Math.floor(Date.now() / 1000), + }; + + console.log("1. Anchoring completed payout record with domain attestation..."); + const receipt = await anchorFundablePayout(samplePayout, { + secretKey: payer.secret(), + }); + + console.log(`- IPFS CID: ${receipt.cid}`); + console.log(`- Attestation Domain: ${receipt.attestation.domain}`); + console.log(`- Attestation Message: ${receipt.attestation.attestationMessage}`); + console.log(`- Signer (Payer): ${receipt.attestation.signerPublicKey}`); + console.log(`- Signature (Base64): ${receipt.attestation.signatureBase64.slice(0, 32)}...\n`); + + console.log("2. Performing independent verification (SHA-256 + Ed25519)..."); + const verification = verifyAnchoredPayoutReceipt(receipt); + + console.log(`- Valid: ${verification.valid ? "YES (Integrity Confirmed)" : "NO"}`); + console.log(`- Hash Recomputed: ${verification.computedHash}`); + console.log(`- Signature Match: ${verification.signatureValid}`); +} + +main().catch(console.error); \ No newline at end of file diff --git a/package.json b/package.json index 1a3f6f8..e3364ad 100644 --- a/package.json +++ b/package.json @@ -1,84 +1,89 @@ { - "name": "@fundable/sdk", - "version": "0.1.0", - "private": false, - "description": "Multichain TypeScript SDK for Fundable Protocol; Stellar adapter included", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/Fundable-Protocol/fundable-sdk.git" + "name": "@fundable/sdk", + "version": "0.1.0", + "private": false, + "description": "Multichain TypeScript SDK for Fundable Protocol; Stellar adapter included", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Fundable-Protocol/fundable-sdk.git" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "docs", + "examples", + "CHANGELOG.md", + "README.md" + ], + "homepage": "https://fundable-finance.gitbook.io/fundable-finance-docs/", + "bugs": { + "url": "https://github.com/Fundable-Protocol/fundable-sdk/issues" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org" + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" }, - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "docs", - "examples", - "CHANGELOG.md", - "README.md" - ], - "homepage": "https://fundable-finance.gitbook.io/fundable-finance-docs/", - "bugs": { - "url": "https://github.com/Fundable-Protocol/fundable-sdk/issues" + "./stellar": { + "types": "./dist/stellar/index.d.ts", + "import": "./dist/stellar/index.js" }, - "sideEffects": false, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./core": { - "types": "./dist/core/index.d.ts", - "import": "./dist/core/index.js" - }, - "./stellar": { - "types": "./dist/stellar/index.d.ts", - "import": "./dist/stellar/index.js" - } - }, - "scripts": { - "clean": "node scripts/clean.mjs", - "build": "pnpm clean && tsc", - "dev": "tsc --watch", - "generate:flow": "node scripts/generate-stellar-binding.mjs flow", - "generate:router": "node scripts/generate-stellar-binding.mjs router", - "generate:stream-nft": "node scripts/generate-stellar-binding.mjs stream_nft", - "generate:paymaster": "node scripts/generate-stellar-binding.mjs paymaster", - "test": "vitest run", - "test:testnet": "FUNDABLE_TESTNET_INTEGRATION=1 vitest run src/stellar/testnet.integration.test.ts", - "typecheck": "tsc --noEmit", - "docs:check": "node scripts/check-doc-links.mjs", - "examples:typecheck": "pnpm build && tsc -p examples/tsconfig.json", - "example:read-flow": "pnpm build && tsx examples/read-flow.ts", - "example:create-router-flow": "pnpm build && tsx examples/create-router-flow.ts", - "example:create-router-lockup": "pnpm build && tsx examples/create-router-lockup.ts", - "example:withdraw-router": "pnpm build && tsx examples/withdraw-router.ts", - "example:read-stream-nft": "pnpm build && tsx examples/read-stream-nft.ts", - "example:check-paymaster": "pnpm build && tsx examples/check-paymaster.ts", - "package:verify": "node scripts/verify-packed-package.mjs", - "verify": "pnpm test && pnpm typecheck && pnpm examples:typecheck && pnpm docs:check", - "prepack": "pnpm verify" - }, - "engines": { - "node": ">=20" - }, - "packageManager": "pnpm@10.18.0", - "dependencies": { - "@stellar/stellar-sdk": "^14.4.3", - "buffer": "^6.0.3" - }, - "devDependencies": { - "@types/node": "^20", - "tsx": "^4.23.1", - "typescript": "^5", - "vitest": "^3.2.4" - }, - "peerDependencies": { - "@stellar/stellar-sdk": "^14.4.3" + "./audit": { + "types": "./dist/audit/index.d.ts", + "import": "./dist/audit/index.js" } -} + }, + "scripts": { + "clean": "node scripts/clean.mjs", + "build": "pnpm clean && tsc", + "dev": "tsc --watch", + "generate:flow": "node scripts/generate-stellar-binding.mjs flow", + "generate:router": "node scripts/generate-stellar-binding.mjs router", + "generate:stream-nft": "node scripts/generate-stellar-binding.mjs stream_nft", + "generate:paymaster": "node scripts/generate-stellar-binding.mjs paymaster", + "test": "vitest run", + "test:testnet": "FUNDABLE_TESTNET_INTEGRATION=1 vitest run src/stellar/testnet.integration.test.ts", + "typecheck": "tsc --noEmit", + "docs:check": "node scripts/check-doc-links.mjs", + "examples:typecheck": "pnpm build && tsc -p examples/tsconfig.json", + "example:read-flow": "pnpm build && tsx examples/read-flow.ts", + "example:create-router-flow": "pnpm build && tsx examples/create-router-flow.ts", + "example:create-router-lockup": "pnpm build && tsx examples/create-router-lockup.ts", + "example:withdraw-router": "pnpm build && tsx examples/withdraw-router.ts", + "example:read-stream-nft": "pnpm build && tsx examples/read-stream-nft.ts", + "example:check-paymaster": "pnpm build && tsx examples/check-paymaster.ts", + "example:verify-anchored-payout": "pnpm build && tsx examples/verify-anchored-payout.ts", + "package:verify": "node scripts/verify-packed-package.mjs", + "verify": "pnpm test && pnpm typecheck && pnpm examples:typecheck && pnpm docs:check", + "prepack": "pnpm verify" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@10.18.0", + "dependencies": { + "@stellar/stellar-sdk": "^14.4.3", + "buffer": "^6.0.3" + }, + "devDependencies": { + "@types/node": "^20", + "tsx": "^4.23.1", + "typescript": "^5", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "@stellar/stellar-sdk": "^14.4.3" + } +} \ No newline at end of file diff --git a/src/audit/adapter.test.ts b/src/audit/adapter.test.ts new file mode 100644 index 0000000..15c3f4c --- /dev/null +++ b/src/audit/adapter.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + computeCanonicalAuditHash, + formatAttestationMessage, + signPayoutAttestation, + anchorFundablePayout, + verifyAnchoredPayoutReceipt, + type FundablePayoutAuditRecord, +} from "./index.js"; + +describe("Nirium Audit Trail Adapter for Fundable Payouts", () => { + const payerKeypair = Keypair.random(); + const recipientKeypair = Keypair.random(); + const tokenContractId = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + const sampleRecord: FundablePayoutAuditRecord = { + protocol: "fundable", + chain: "stellar", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CA3D5KRYMCMCZVAC7OHQHG2CAUAQ77PDRWNXD7BOD7X62F26T4ZMY54K", + operation: "create_flow", + txHash: "a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0", + sender: payerKeypair.publicKey(), + recipient: recipientKeypair.publicKey(), + token: tokenContractId, + amountOrRate: "10000000", + startTime: 1724774400, + timestamp: 1724774405, + }; + + it("computes deterministic canonical SHA-256 hash regardless of key order", () => { + const hash1 = computeCanonicalAuditHash(sampleRecord); + + const permutedRecord = { + timestamp: sampleRecord.timestamp, + recipient: sampleRecord.recipient, + token: sampleRecord.token, + amountOrRate: sampleRecord.amountOrRate, + sender: sampleRecord.sender, + operation: sampleRecord.operation, + txHash: sampleRecord.txHash, + contractId: sampleRecord.contractId, + chain: sampleRecord.chain, + networkPassphrase: sampleRecord.networkPassphrase, + protocol: sampleRecord.protocol, + startTime: sampleRecord.startTime, + } as FundablePayoutAuditRecord; + + const hash2 = computeCanonicalAuditHash(permutedRecord); + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); + }); + + it("formats domain-separated attestation message as nirium-audit-v1:", () => { + const hash = computeCanonicalAuditHash(sampleRecord); + const message = formatAttestationMessage(hash); + expect(message).toBe(`nirium-audit-v1:${hash}`); + }); + + it("signs and anchors a payout record returning valid receipt and CID", async () => { + const receipt = await anchorFundablePayout(sampleRecord, { + secretKey: payerKeypair.secret(), + }); + + expect(receipt.cid).toBeDefined(); + expect(receipt.cid.startsWith("bafk")).toBe(true); + expect(receipt.attestation.signerPublicKey).toBe(payerKeypair.publicKey()); + expect(receipt.attestation.signatureBase64).toBeTruthy(); + + const verification = verifyAnchoredPayoutReceipt(receipt); + expect(verification.valid).toBe(true); + expect(verification.signatureValid).toBe(true); + expect(verification.signer).toBe(payerKeypair.publicKey()); + }); + + it("rejects verification if receipt payload is tampered", async () => { + const receipt = await anchorFundablePayout(sampleRecord, { + secretKey: payerKeypair.secret(), + }); + + const tamperedReceipt = { + ...receipt, + record: { + ...receipt.record, + amountOrRate: "999999999999", + }, + }; + + const verification = verifyAnchoredPayoutReceipt(tamperedReceipt); + expect(verification.valid).toBe(false); + expect(verification.reason).toContain("Hash mismatch"); + }); + + it("rejects verification if signature is invalid", async () => { + const anotherKeypair = Keypair.random(); + const receipt = await anchorFundablePayout(sampleRecord, { + secretKey: payerKeypair.secret(), + }); + + const invalidReceipt = { + ...receipt, + attestation: { + ...receipt.attestation, + signerPublicKey: anotherKeypair.publicKey(), + }, + }; + + const verification = verifyAnchoredPayoutReceipt(invalidReceipt); + expect(verification.valid).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/audit/adapter.ts b/src/audit/adapter.ts new file mode 100644 index 0000000..b2e4e16 --- /dev/null +++ b/src/audit/adapter.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; +import { Keypair } from "@stellar/stellar-sdk"; +import type { + FundablePayoutAuditRecord, + NiriumAttestation, + AnchoredAuditTrailReceipt, +} from "./types.js"; + +const DOMAIN_PREFIX = "nirium-audit-v1"; + +export function computeCanonicalAuditHash(record: FundablePayoutAuditRecord): string { + const canonicalJson = JSON.stringify(record, Object.keys(record).sort()); + return createHash("sha256").update(canonicalJson, "utf8").digest("hex"); +} + +export function formatAttestationMessage(recordHash: string): string { + return `${DOMAIN_PREFIX}:${recordHash}`; +} + +export function signPayoutAttestation( + recordHash: string, + secretKey: string, +): NiriumAttestation { + const keypair = Keypair.fromSecret(secretKey); + const attestationMessage = formatAttestationMessage(recordHash); + const messageBuffer = Buffer.from(attestationMessage, "utf8"); + const signature = keypair.sign(messageBuffer); + + return { + domain: DOMAIN_PREFIX, + recordHash, + attestationMessage, + signerPublicKey: keypair.publicKey(), + signatureBase64: signature.toString("base64"), + }; +} + +export interface NiriumAnchorOptions { + niriumNodeUrl?: string; + secretKey?: string; + mockCidGenerator?: (payload: unknown) => string; +} + +export async function anchorFundablePayout( + record: FundablePayoutAuditRecord, + options?: NiriumAnchorOptions, +): Promise { + const recordHash = computeCanonicalAuditHash(record); + + let attestation: NiriumAttestation; + if (options?.secretKey) { + attestation = signPayoutAttestation(recordHash, options.secretKey); + } else { + attestation = { + domain: DOMAIN_PREFIX, + recordHash, + attestationMessage: formatAttestationMessage(recordHash), + signerPublicKey: record.sender, + signatureBase64: "", + }; + } + + const payload = { + record, + attestation, + timestamp: Date.now(), + }; + + let cid = ""; + if (options?.mockCidGenerator) { + cid = options.mockCidGenerator(payload); + } else if (options?.niriumNodeUrl) { + const response = await fetch(`${options.niriumNodeUrl}/api/v1/anchor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to anchor to Nirium node: ${response.statusText}`); + } + + const data = (await response.json()) as { cid: string }; + cid = data.cid; + } else { + const payloadHash = createHash("sha256") + .update(JSON.stringify(payload)) + .digest("hex"); + cid = `bafkrei${payloadHash.slice(0, 52)}`; + } + + return { + cid, + record, + attestation, + anchoredAt: payload.timestamp, + }; +} \ No newline at end of file diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 0000000..2ded6e6 --- /dev/null +++ b/src/audit/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./adapter.js"; +export * from "./verifier.js"; \ No newline at end of file diff --git a/src/audit/types.ts b/src/audit/types.ts new file mode 100644 index 0000000..c110e7d --- /dev/null +++ b/src/audit/types.ts @@ -0,0 +1,38 @@ +export interface FundablePayoutAuditRecord { + protocol: "fundable"; + chain: "stellar"; + networkPassphrase: string; + contractId: string; + operation: "create_flow" | "create_lockup" | "withdraw" | "flow_deposit" | "custom_payout"; + txHash: string; + sender: string; + recipient: string; + token: string; + amountOrRate: string; + startTime?: number; + timestamp: number; +} + +export interface NiriumAttestation { + domain: string; + recordHash: string; + attestationMessage: string; + signerPublicKey: string; + signatureBase64: string; +} + +export interface AnchoredAuditTrailReceipt { + cid: string; + record: FundablePayoutAuditRecord; + attestation: NiriumAttestation; + anchoredAt: number; +} + +export interface VerificationResult { + valid: boolean; + computedHash: string; + expectedHash: string; + signatureValid: boolean; + signer: string; + reason?: string; +} \ No newline at end of file diff --git a/src/audit/verifier.ts b/src/audit/verifier.ts new file mode 100644 index 0000000..90249b9 --- /dev/null +++ b/src/audit/verifier.ts @@ -0,0 +1,66 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { + computeCanonicalAuditHash, + formatAttestationMessage, +} from "./adapter.js"; +import type { AnchoredAuditTrailReceipt, VerificationResult } from "./types.js"; + +export function verifyAnchoredPayoutReceipt( + receipt: AnchoredAuditTrailReceipt, +): VerificationResult { + const computedHash = computeCanonicalAuditHash(receipt.record); + const expectedHash = receipt.attestation.recordHash; + + if (computedHash !== expectedHash) { + return { + valid: false, + computedHash, + expectedHash, + signatureValid: false, + signer: receipt.attestation.signerPublicKey, + reason: "Hash mismatch: canonical record does not match the attested hash.", + }; + } + + if (!receipt.attestation.signatureBase64) { + return { + valid: true, + computedHash, + expectedHash, + signatureValid: false, + signer: receipt.attestation.signerPublicKey, + reason: "Unsigned attestation record.", + }; + } + + try { + const keypair = Keypair.fromPublicKey(receipt.attestation.signerPublicKey); + const expectedMessage = formatAttestationMessage(computedHash); + const messageBuffer = Buffer.from(expectedMessage, "utf8"); + const signatureBuffer = Buffer.from( + receipt.attestation.signatureBase64, + "base64", + ); + + const signatureValid = keypair.verify(messageBuffer, signatureBuffer); + + return { + valid: signatureValid, + computedHash, + expectedHash, + signatureValid, + signer: receipt.attestation.signerPublicKey, + reason: signatureValid ? undefined : "Invalid cryptographic signature for signer.", + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + valid: false, + computedHash, + expectedHash, + signatureValid: false, + signer: receipt.attestation.signerPublicKey, + reason: `Signature verification failed: ${message}`, + }; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6c8c8d2..377efbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,4 @@ export const VERSION = "0.1.0"; export * from "./client.js"; export * from "./core/index.js"; export * from "./stellar/index.js"; +export * from "./audit/index.js"; \ No newline at end of file