diff --git a/app/api/snippets/[id]/transaction-status/route.ts b/app/api/snippets/[id]/transaction-status/route.ts index ffa2764..a13e07a 100644 --- a/app/api/snippets/[id]/transaction-status/route.ts +++ b/app/api/snippets/[id]/transaction-status/route.ts @@ -10,7 +10,10 @@ export async function GET( try { const { id } = await params; - const transactions = await service.getBySnippetId(id); + const [transactions, snippetTransactions] = await Promise.all([ + service.getBySnippetId(id), + service.getSnippetTransactions(id), + ]); return NextResponse.json({ snippetId: id, @@ -28,6 +31,18 @@ export async function GET( createdAt: tx.created_at, updatedAt: tx.updated_at, })), + // Stellar transactions whose memo references this snippet. + snippetTransactions: snippetTransactions.map((tx) => ({ + id: tx.id, + memoRef: tx.memoRef, + transactionHash: tx.transactionHash, + ledgerSequence: tx.ledgerSequence, + status: tx.status, + txType: tx.txType, + errorMessage: tx.errorMessage, + createdAt: tx.createdAt, + updatedAt: tx.updatedAt, + })), }); } catch (error) { console.error("[TransactionStatus] Error:", error); diff --git a/lib/activity-logger.ts b/lib/activity-logger.ts index eb146c8..ed6ce8f 100644 --- a/lib/activity-logger.ts +++ b/lib/activity-logger.ts @@ -38,7 +38,11 @@ export type ActivityAction = | "stellar.tx.confirmed" | "stellar.tx.applied" | "stellar.tx.failed" - | "stellar.tx.dead"; + | "stellar.tx.dead" + | "stellar.memo.attached" + | "snippet.transaction.linked" + | "snippet.transaction.confirmed" + | "snippet.transaction.failed"; export interface ActivityLogEntry { id: string; diff --git a/lib/snippet-memo.test.ts b/lib/snippet-memo.test.ts new file mode 100644 index 0000000..59f2770 --- /dev/null +++ b/lib/snippet-memo.test.ts @@ -0,0 +1,166 @@ +import { + SNIPPET_BATCH_MEMO_PREFIX, + SNIPPET_MEMO_PREFIX, + STELLAR_MEMO_TEXT_MAX_BYTES, + SnippetMemoError, + assertMemoWithinStellarLimit, + assertSnippetMemoRef, + buildBatchSnippetMemo, + buildSnippetMemo, + generateSnippetMemoRef, + parseBatchSnippetMemo, + parseSnippetMemo, +} from "@/lib/snippet-memo"; + +const SNIPPET_ID = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + +describe("snippet-memo", () => { + describe("generateSnippetMemoRef", () => { + it("builds a deterministic snippet-scoped reference", () => { + const ref = generateSnippetMemoRef(SNIPPET_ID); + + expect(ref).toBe(generateSnippetMemoRef(SNIPPET_ID)); + expect(ref).toMatch(/^snip:[0-9a-f]{12}$/); + expect(ref.startsWith(SNIPPET_MEMO_PREFIX)).toBe(true); + }); + + it("stays inside the 28-byte memo_text limit", () => { + const ref = generateSnippetMemoRef(SNIPPET_ID); + + expect(Buffer.byteLength(ref, "utf8")).toBeLessThanOrEqual( + STELLAR_MEMO_TEXT_MAX_BYTES, + ); + }); + + it("never collides across different snippets", () => { + const refs = new Set( + Array.from({ length: 50 }, (_, index) => + generateSnippetMemoRef(`snippet-${index}`), + ), + ); + + expect(refs.size).toBe(50); + }); + + it("rejects a missing snippet id", () => { + expect(() => generateSnippetMemoRef("")).toThrow(SnippetMemoError); + expect(() => generateSnippetMemoRef(" ")).toThrow(SnippetMemoError); + }); + }); + + describe("buildSnippetMemo", () => { + it("returns the snippet reference when no content hash is given", () => { + expect(buildSnippetMemo(SNIPPET_ID)).toBe(generateSnippetMemoRef(SNIPPET_ID)); + }); + + it("appends a short hash suffix without exceeding the byte limit", () => { + const contentHash = "a".repeat(64); + const memo = buildSnippetMemo(SNIPPET_ID, contentHash); + + expect(memo.startsWith(`${generateSnippetMemoRef(SNIPPET_ID)}:`)).toBe(true); + expect(Buffer.byteLength(memo, "utf8")).toBeLessThanOrEqual( + STELLAR_MEMO_TEXT_MAX_BYTES, + ); + }); + + it("rejects an empty snippet id", () => { + expect(() => buildSnippetMemo("")).toThrow(SnippetMemoError); + }); + }); + + describe("buildBatchSnippetMemo", () => { + it("builds a bounded batch-scoped reference", () => { + const memo = buildBatchSnippetMemo("b".repeat(64)); + + expect(memo.startsWith(SNIPPET_BATCH_MEMO_PREFIX)).toBe(true); + expect(Buffer.byteLength(memo, "utf8")).toBeLessThanOrEqual( + STELLAR_MEMO_TEXT_MAX_BYTES, + ); + }); + + it("rejects a missing batch hash", () => { + expect(() => buildBatchSnippetMemo("")).toThrow(SnippetMemoError); + }); + }); + + describe("assertMemoWithinStellarLimit", () => { + it("accepts a memo of exactly 28 bytes", () => { + const memo = "a".repeat(STELLAR_MEMO_TEXT_MAX_BYTES); + + expect(assertMemoWithinStellarLimit(memo)).toBe(memo); + }); + + it("throws instead of truncating an over-long memo", () => { + expect(() => + assertMemoWithinStellarLimit("a".repeat(STELLAR_MEMO_TEXT_MAX_BYTES + 1)), + ).toThrow(/limited to 28 bytes/); + }); + + it("measures multi-byte characters in bytes, not characters", () => { + const memo = "é".repeat(15); // 30 bytes, 15 characters + + expect(() => assertMemoWithinStellarLimit(memo)).toThrow(SnippetMemoError); + }); + + it("rejects empty memos", () => { + expect(() => assertMemoWithinStellarLimit("")).toThrow(SnippetMemoError); + }); + }); + + describe("parseSnippetMemo", () => { + it("extracts the reference from a plain memo", () => { + const ref = generateSnippetMemoRef(SNIPPET_ID); + + expect(parseSnippetMemo(ref)).toBe(ref); + }); + + it("ignores the hash suffix", () => { + const ref = generateSnippetMemoRef(SNIPPET_ID); + + expect(parseSnippetMemo(`${ref}:deadbeef`)).toBe(ref); + }); + + it("returns null for memos that are not snippet references", () => { + expect(parseSnippetMemo(undefined)).toBeNull(); + expect(parseSnippetMemo(null)).toBeNull(); + expect(parseSnippetMemo("")).toBeNull(); + expect(parseSnippetMemo("tr:abc:def:ghi")).toBeNull(); + expect(parseSnippetMemo("lic:abcdef12")).toBeNull(); + expect(parseSnippetMemo("snip:nothex")).toBeNull(); + expect(parseSnippetMemo("snip:ABCDEF012345")).toBeNull(); + expect(parseSnippetMemo(`snip:${"a".repeat(13)}`)).toBeNull(); + }); + }); + + describe("parseBatchSnippetMemo", () => { + it("extracts a batch reference", () => { + const memo = buildBatchSnippetMemo("c".repeat(64)); + + expect(parseBatchSnippetMemo(memo)).toBe(memo); + }); + + it("returns null for snippet-scoped references", () => { + expect(parseBatchSnippetMemo(generateSnippetMemoRef(SNIPPET_ID))).toBeNull(); + expect(parseBatchSnippetMemo("batch:aaaa")).toBeNull(); + }); + }); + + describe("assertSnippetMemoRef", () => { + it("accepts snippet and batch references", () => { + expect(assertSnippetMemoRef(generateSnippetMemoRef(SNIPPET_ID))).toBe( + generateSnippetMemoRef(SNIPPET_ID), + ); + expect(assertSnippetMemoRef(buildBatchSnippetMemo("d".repeat(64)))).toBe( + buildBatchSnippetMemo("d".repeat(64)), + ); + }); + + it("rejects legacy or malformed memo references", () => { + expect(() => assertSnippetMemoRef("s:abcdef12:deadbeef")).toThrow( + SnippetMemoError, + ); + expect(() => assertSnippetMemoRef("batch:abcdef")).toThrow(SnippetMemoError); + expect(() => assertSnippetMemoRef("snip:")).toThrow(SnippetMemoError); + }); + }); +}); diff --git a/lib/snippet-memo.ts b/lib/snippet-memo.ts new file mode 100644 index 0000000..3942d8a --- /dev/null +++ b/lib/snippet-memo.ts @@ -0,0 +1,134 @@ +import crypto from "crypto"; + +/** + * Snippet memo references. + * + * Stellar `memo_text` payloads are capped at 28 bytes. Every reference built + * here stays within that limit and is validated before it is submitted: + * an over-long memo throws instead of being silently truncated. + */ + +/** Hard limit imposed by Stellar for `MEMO_TEXT` payloads. */ +export const STELLAR_MEMO_TEXT_MAX_BYTES = 28; + +/** Prefix that marks a transaction memo as a snippet reference. */ +export const SNIPPET_MEMO_PREFIX = "snip:"; + +/** Prefix used when one transaction anchors several snippets (batch). */ +export const SNIPPET_BATCH_MEMO_PREFIX = "snipb:"; + +const SNIPPET_MEMO_ID_LENGTH = 12; +const SNIPPET_MEMO_HASH_LENGTH = 8; + +export class SnippetMemoError extends Error { + constructor(message: string) { + super(message); + this.name = "SnippetMemoError"; + } +} + +function requireNonEmpty(value: string, label: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new SnippetMemoError(`${label} is required to build a snippet memo reference`); + } + return value.trim(); +} + +/** + * Deterministic, collision-resistant reference for a snippet: `snip:<12 hex>` + * (17 bytes). Deriving it from the snippet id keeps one reference per snippet, + * so two snippets can never share the same memo identifier. + */ +export function generateSnippetMemoRef(snippetId: string): string { + const id = requireNonEmpty(snippetId, "snippetId"); + const digest = crypto.createHash("sha256").update(id).digest("hex"); + return `${SNIPPET_MEMO_PREFIX}${digest.slice(0, SNIPPET_MEMO_ID_LENGTH)}`; +} + +/** Reference shared by every snippet anchored in a single batch transaction. */ +export function generateBatchSnippetMemoRef(batchHash: string): string { + const hash = requireNonEmpty(batchHash, "batchHash"); + return `${SNIPPET_BATCH_MEMO_PREFIX}${hash.slice(0, SNIPPET_MEMO_ID_LENGTH)}`; +} + +/** + * Enforce Stellar's 28-byte `memo_text` limit. + * + * Throws (never truncates) so a memo can not silently change meaning between + * the database and the ledger. + */ +export function assertMemoWithinStellarLimit(memo: string): string { + if (typeof memo !== "string" || memo.length === 0) { + throw new SnippetMemoError("Stellar memo must be a non-empty string"); + } + + const bytes = Buffer.byteLength(memo, "utf8"); + if (bytes > STELLAR_MEMO_TEXT_MAX_BYTES) { + throw new SnippetMemoError( + `Stellar memo_text is limited to ${STELLAR_MEMO_TEXT_MAX_BYTES} bytes, got ${bytes} for "${memo}"`, + ); + } + + return memo; +} + +/** + * Memo for a snippet transaction: `snip:` or `snip::` + * (at most 26 bytes). The optional hash suffix keeps the anchored-content hint + * the previous `s::` format carried. + */ +export function buildSnippetMemo(snippetId: string, contentHash?: string): string { + const ref = generateSnippetMemoRef(snippetId); + const memo = contentHash + ? `${ref}:${contentHash.slice(0, SNIPPET_MEMO_HASH_LENGTH)}` + : ref; + + return assertMemoWithinStellarLimit(memo); +} + +/** Memo for a batch transaction: `snipb:<12 hex>` (18 bytes). */ +export function buildBatchSnippetMemo(batchHash: string): string { + return assertMemoWithinStellarLimit(generateBatchSnippetMemoRef(batchHash)); +} + +/** Returns the canonical snippet reference carried by a memo, or null. */ +export function parseSnippetMemo(memo?: string | null): string | null { + if (typeof memo !== "string" || !memo.startsWith(SNIPPET_MEMO_PREFIX)) { + return null; + } + + const [id] = memo.slice(SNIPPET_MEMO_PREFIX.length).split(":"); + if (!id || !new RegExp(`^[0-9a-f]{${SNIPPET_MEMO_ID_LENGTH}}$`).test(id)) { + return null; + } + + return `${SNIPPET_MEMO_PREFIX}${id}`; +} + +/** Returns the canonical batch reference carried by a memo, or null. */ +export function parseBatchSnippetMemo(memo?: string | null): string | null { + if (typeof memo !== "string" || !memo.startsWith(SNIPPET_BATCH_MEMO_PREFIX)) { + return null; + } + + const id = memo.slice(SNIPPET_BATCH_MEMO_PREFIX.length).split(":")[0]; + if (!id || !new RegExp(`^[0-9a-f]{${SNIPPET_MEMO_ID_LENGTH}}$`).test(id)) { + return null; + } + + return `${SNIPPET_BATCH_MEMO_PREFIX}${id}`; +} + +/** + * Validate a reference before it is persisted, rejecting malformed or + * over-long values instead of storing an association the chain would refuse. + */ +export function assertSnippetMemoRef(reference: string): string { + if (!parseSnippetMemo(reference) && !parseBatchSnippetMemo(reference)) { + throw new SnippetMemoError( + `"${reference}" is not a valid snippet memo reference (expected "${SNIPPET_MEMO_PREFIX}<12 hex>" or "${SNIPPET_BATCH_MEMO_PREFIX}<12 hex>")`, + ); + } + + return assertMemoWithinStellarLimit(reference); +} diff --git a/lib/snippet-transaction-association.test.ts b/lib/snippet-transaction-association.test.ts new file mode 100644 index 0000000..d13b87f --- /dev/null +++ b/lib/snippet-transaction-association.test.ts @@ -0,0 +1,247 @@ +import type { PendingStellarTransaction } from "@/lib/stellar-recovery.types"; + +jest.mock("@/lib/stellar-recovery.repository", () => ({ + StellarRecoveryRepository: jest.fn(), +})); + +jest.mock("@/lib/snippet-transaction.repository", () => ({ + SnippetTransactionRepository: jest.fn(), +})); + +jest.mock("@/lib/activity-logger", () => ({ + appendActivityLog: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("@/lib/stellar", () => ({ + submitOwnershipTransferMemoToStellar: jest.fn(), + submitHashToStellar: jest.fn(), + submitBatchHashToStellar: jest.fn(), + mintSnippetLicenseOnStellar: jest.fn(), + classifyStellarError: jest + .fn() + .mockReturnValue({ retryable: false, reason: "unknown_error" }), +})); + +jest.mock("@neondatabase/serverless", () => ({ + neon: jest.fn(() => jest.fn().mockResolvedValue([])), +})); + +jest.mock("stellar-sdk", () => ({ + Horizon: { + Server: jest.fn(() => ({ + transactions: jest.fn(() => ({ + hash: jest.fn(() => ({ + call: jest.fn().mockResolvedValue({ ledger: 12345 }), + })), + })), + })), + }, +})); + +import { StellarRecoveryService } from "@/lib/stellar-recovery.service"; +import { StellarRecoveryRepository } from "@/lib/stellar-recovery.repository"; +import { appendActivityLog } from "@/lib/activity-logger"; +import * as stellar from "@/lib/stellar"; + +const MEMO_REF = "snip:0123456789ab"; +const BATCH_MEMO_REF = "snipb:fedcba987654"; + +function createRecord( + overrides: Partial = {}, +): PendingStellarTransaction { + return { + id: "rec-1", + idempotency_key: "anchor:snippet-1:2026-09-25", + tx_type: "hash_anchoring", + status: "pending", + payload: { snippetId: "snippet-1", contentHash: "hash-1" }, + stellar_tx_hash: null, + stellar_ledger: null, + attempt_count: 0, + max_attempts: 5, + last_error: null, + next_retry_at: null, + callback_status: "pending", + created_at: "2026-09-25T00:00:00.000Z", + updated_at: "2026-09-25T00:00:00.000Z", + ...overrides, + }; +} + +function loggedActions(): string[] { + return (appendActivityLog as jest.Mock).mock.calls.map((call) => call[0]); +} + +describe("StellarRecoveryService snippet memo associations", () => { + let repo: Record; + let snippetTransactions: Record; + let service: StellarRecoveryService; + + beforeEach(() => { + repo = { + createPending: jest.fn(), + findByIdempotencyKey: jest.fn().mockResolvedValue(null), + findById: jest.fn(), + findBySnippetId: jest.fn(), + markSubmitted: jest.fn(), + markConfirmed: jest.fn(), + markApplied: jest.fn(), + markFailed: jest.fn(), + markDead: jest.fn(), + markCallbackFailed: jest.fn(), + findRetryable: jest.fn(), + findConfirmedNeedingCallback: jest.fn(), + }; + + snippetTransactions = { + linkPending: jest.fn(), + markConfirmed: jest.fn(), + markFailed: jest.fn(), + findBySnippetId: jest.fn(), + findByTransactionHash: jest.fn(), + findByMemoRef: jest.fn(), + }; + + service = new StellarRecoveryService( + repo as unknown as StellarRecoveryRepository, + snippetTransactions as any, + ); + + jest.clearAllMocks(); + }); + + it("links a submitted transaction to its snippet through the memo reference", async () => { + repo.createPending.mockResolvedValue(createRecord()); + (stellar.submitHashToStellar as jest.Mock).mockResolvedValue({ + success: true, + transactionHash: "tx-hash-1", + ledger: 100, + memo: `${MEMO_REF}:hash1`, + memoRef: MEMO_REF, + }); + + await service.submitHashAnchoring({ + idempotencyKey: "anchor:snippet-1:2026-09-25", + snippetId: "snippet-1", + contentHash: "hash-1", + }); + + expect(snippetTransactions.linkPending).toHaveBeenCalledWith({ + snippetId: "snippet-1", + transactionHash: "tx-hash-1", + memoRef: MEMO_REF, + txType: "hash_anchoring", + }); + expect(snippetTransactions.markConfirmed).toHaveBeenCalledWith({ + transactionHash: "tx-hash-1", + ledgerSequence: 12345, + }); + expect(loggedActions()).toContain("stellar.memo.attached"); + expect(loggedActions()).toContain("snippet.transaction.linked"); + expect(loggedActions()).toContain("snippet.transaction.confirmed"); + }); + + it("associates every snippet referenced by a batch transaction", async () => { + repo.createPending.mockResolvedValue( + createRecord({ + idempotency_key: "batch:1", + tx_type: "batch_hash", + payload: { + snippets: [ + { id: "snippet-1", hash: "hash-1" }, + { id: "snippet-2", hash: "hash-2" }, + ], + }, + }), + ); + (stellar.submitBatchHashToStellar as jest.Mock).mockResolvedValue({ + success: true, + transactionHash: "tx-batch", + memo: BATCH_MEMO_REF, + memoRef: BATCH_MEMO_REF, + }); + + await service.submitBatchHash({ + idempotencyKey: "batch:1", + snippets: [ + { id: "snippet-1", hash: "hash-1" }, + { id: "snippet-2", hash: "hash-2" }, + ], + }); + + expect(snippetTransactions.linkPending).toHaveBeenCalledTimes(2); + expect(snippetTransactions.linkPending).toHaveBeenCalledWith( + expect.objectContaining({ snippetId: "snippet-1", memoRef: BATCH_MEMO_REF }), + ); + expect(snippetTransactions.linkPending).toHaveBeenCalledWith( + expect.objectContaining({ snippetId: "snippet-2", memoRef: BATCH_MEMO_REF }), + ); + }); + + it("never writes an association when the transaction has no memo reference", async () => { + repo.createPending.mockResolvedValue(createRecord()); + (stellar.submitHashToStellar as jest.Mock).mockResolvedValue({ + success: true, + transactionHash: "tx-hash-2", + ledger: 200, + }); + + await service.submitHashAnchoring({ + idempotencyKey: "anchor:snippet-2:2026-09-25", + snippetId: "snippet-1", + contentHash: "hash-1", + }); + + expect(snippetTransactions.linkPending).not.toHaveBeenCalled(); + expect(repo.markSubmitted).toHaveBeenCalledWith({ + id: "rec-1", + stellarTxHash: "tx-hash-2", + }); + }); + + it("keeps the transaction flow alive when association persistence fails", async () => { + repo.createPending.mockResolvedValue(createRecord()); + (stellar.submitHashToStellar as jest.Mock).mockResolvedValue({ + success: true, + transactionHash: "tx-hash-3", + ledger: 300, + memoRef: MEMO_REF, + }); + snippetTransactions.linkPending.mockRejectedValue(new Error("database down")); + + await expect( + service.submitHashAnchoring({ + idempotencyKey: "anchor:snippet-3:2026-09-25", + snippetId: "snippet-1", + contentHash: "hash-1", + }), + ).resolves.toBeDefined(); + + expect(repo.markSubmitted).toHaveBeenCalled(); + expect(repo.markDead).not.toHaveBeenCalled(); + expect(snippetTransactions.markConfirmed).toHaveBeenCalled(); + }); + + it("marks an existing association as failed when submission does not succeed", async () => { + repo.createPending.mockResolvedValue( + createRecord({ stellar_tx_hash: "tx-previous" }), + ); + (stellar.submitHashToStellar as jest.Mock).mockResolvedValue({ + success: false, + error: "tx_failed", + }); + + await service.submitHashAnchoring({ + idempotencyKey: "anchor:snippet-4:2026-09-25", + snippetId: "snippet-1", + contentHash: "hash-1", + }); + + expect(snippetTransactions.linkPending).not.toHaveBeenCalled(); + expect(snippetTransactions.markFailed).toHaveBeenCalledWith({ + transactionHash: "tx-previous", + errorMessage: "tx_failed", + }); + expect(loggedActions()).toContain("snippet.transaction.failed"); + }); +}); diff --git a/lib/snippet-transaction.repository.ts b/lib/snippet-transaction.repository.ts new file mode 100644 index 0000000..687afed --- /dev/null +++ b/lib/snippet-transaction.repository.ts @@ -0,0 +1,168 @@ +import crypto from "crypto"; +import { neon } from "@neondatabase/serverless"; +import { assertSnippetMemoRef } from "@/lib/snippet-memo"; +import type { + LinkSnippetTransactionParams, + SnippetTransaction, + SnippetTransactionRow, +} from "@/lib/snippet-transaction.types"; + +export type { + LinkSnippetTransactionParams, + SnippetTransaction, + SnippetTransactionRow, +}; + +let sql: ReturnType | null = null; + +function getSql() { + if (!sql) { + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL environment variable is not set"); + } + sql = neon(process.env.DATABASE_URL); + } + return sql; +} + +function toSnippetTransaction(row: SnippetTransactionRow): SnippetTransaction { + return { + id: row.id, + snippetId: row.snippet_id, + transactionHash: row.transaction_hash, + memoRef: row.memo_ref, + ledgerSequence: row.ledger_sequence ? Number(row.ledger_sequence) : null, + status: row.status, + txType: row.tx_type, + errorMessage: row.error_message, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export interface SnippetTransactionRepositoryLike { + linkPending(params: LinkSnippetTransactionParams): Promise; + markConfirmed(params: { + transactionHash: string; + ledgerSequence?: number | null; + }): Promise; + markFailed(params: { + transactionHash: string; + errorMessage: string; + }): Promise; + findBySnippetId(snippetId: string): Promise; + findByTransactionHash(transactionHash: string): Promise; + findByMemoRef(memoRef: string): Promise; +} + +/** + * Persists the many-to-many association between snippets and the Stellar + * transactions that reference them through a memo. + */ +export class SnippetTransactionRepository implements SnippetTransactionRepositoryLike { + /** + * Record a submitted transaction against a snippet. + * + * The memo reference is validated (and rejected when malformed or longer than + * Stellar's 28-byte limit) before anything is written. + */ + async linkPending( + params: LinkSnippetTransactionParams, + ): Promise { + const memoRef = assertSnippetMemoRef(params.memoRef); + + if (!params.snippetId || !params.transactionHash) { + throw new Error("snippetId and transactionHash are required"); + } + + const result = (await getSql()` + INSERT INTO snippet_transactions ( + id, + snippet_id, + transaction_hash, + memo_ref, + status, + tx_type + ) + VALUES ( + ${crypto.randomUUID()}, + ${params.snippetId}, + ${params.transactionHash}, + ${memoRef}, + 'pending', + ${params.txType ?? null} + ) + ON CONFLICT (snippet_id, transaction_hash) DO UPDATE SET + memo_ref = EXCLUDED.memo_ref, + status = 'pending', + error_message = NULL, + updated_at = NOW() + RETURNING * + `) as SnippetTransactionRow[]; + + return toSnippetTransaction(result[0]); + } + + async markConfirmed(params: { + transactionHash: string; + ledgerSequence?: number | null; + }): Promise { + await getSql()` + UPDATE snippet_transactions + SET + status = 'confirmed', + ledger_sequence = COALESCE(${params.ledgerSequence ?? null}, ledger_sequence), + error_message = NULL, + updated_at = NOW() + WHERE transaction_hash = ${params.transactionHash} + AND status <> 'confirmed' + `; + } + + async markFailed(params: { + transactionHash: string; + errorMessage: string; + }): Promise { + await getSql()` + UPDATE snippet_transactions + SET + status = 'failed', + error_message = ${params.errorMessage}, + updated_at = NOW() + WHERE transaction_hash = ${params.transactionHash} + AND status = 'pending' + `; + } + + async findBySnippetId(snippetId: string): Promise { + const rows = (await getSql()` + SELECT * FROM snippet_transactions + WHERE snippet_id = ${snippetId} + ORDER BY created_at DESC + `) as SnippetTransactionRow[]; + + return rows.map(toSnippetTransaction); + } + + async findByTransactionHash( + transactionHash: string, + ): Promise { + const rows = (await getSql()` + SELECT * FROM snippet_transactions + WHERE transaction_hash = ${transactionHash} + LIMIT 1 + `) as SnippetTransactionRow[]; + + return rows.length ? toSnippetTransaction(rows[0]) : null; + } + + async findByMemoRef(memoRef: string): Promise { + const rows = (await getSql()` + SELECT * FROM snippet_transactions + WHERE memo_ref = ${memoRef} + ORDER BY created_at DESC + `) as SnippetTransactionRow[]; + + return rows.map(toSnippetTransaction); + } +} diff --git a/lib/snippet-transaction.types.ts b/lib/snippet-transaction.types.ts new file mode 100644 index 0000000..841e6a2 --- /dev/null +++ b/lib/snippet-transaction.types.ts @@ -0,0 +1,41 @@ +export type SnippetTransactionStatus = "pending" | "confirmed" | "failed"; + +/** + * A Stellar transaction that carries a snippet memo reference. + * + * The row is created as soon as the transaction is submitted (`pending`), and + * its status follows the transaction through to `confirmed` (ledger sequence + * recorded) or `failed`. + */ +export interface SnippetTransaction { + id: string; + snippetId: string; + transactionHash: string; + memoRef: string; + ledgerSequence: number | null; + status: SnippetTransactionStatus; + txType: string | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SnippetTransactionRow { + id: string; + snippet_id: string; + transaction_hash: string; + memo_ref: string; + ledger_sequence: number | null; + status: SnippetTransactionStatus; + tx_type: string | null; + error_message: string | null; + created_at: string; + updated_at: string; +} + +export interface LinkSnippetTransactionParams { + snippetId: string; + transactionHash: string; + memoRef: string; + txType?: string | null; +} diff --git a/lib/stellar-recovery.service.ts b/lib/stellar-recovery.service.ts index cc9d9f1..2eb4063 100644 --- a/lib/stellar-recovery.service.ts +++ b/lib/stellar-recovery.service.ts @@ -1,5 +1,8 @@ import * as StellarSdk from "stellar-sdk"; import { StellarRecoveryRepository } from "./stellar-recovery.repository"; +import { SnippetTransactionRepository } from "./snippet-transaction.repository"; +import type { SnippetTransactionRepositoryLike } from "./snippet-transaction.repository"; +import type { SnippetTransaction } from "./snippet-transaction.types"; import type { CallbackStatus, PendingStellarTransaction, @@ -40,6 +43,7 @@ function sleep(ms: number): Promise { export class StellarRecoveryService { constructor( private readonly repo: StellarRecoveryRepository = new StellarRecoveryRepository(), + private readonly snippetTransactions: SnippetTransactionRepositoryLike = new SnippetTransactionRepository(), ) {} async submitOwnershipTransfer(params: { @@ -189,11 +193,13 @@ export class StellarRecoveryService { if (classification.retryable) { const nextRetry = computeBackoff(record.attempt_count + 1); await this.repo.markFailed({ id: record.id, error, nextRetryAt: nextRetry }); + await this.failMemoAssociation(record, error); console.warn( `[StellarRecovery] Submission failed (retryable): ${record.tx_type} — ${error}`, ); } else { await this.repo.markDead({ id: record.id, error }); + await this.failMemoAssociation(record, error); console.error( `[StellarRecovery] Submission failed (permanent): ${record.tx_type} — ${error}`, ); @@ -206,6 +212,15 @@ export class StellarRecoveryService { stellarTxHash: result.transactionHash, }); + const memoRef = typeof result.memoRef === "string" ? result.memoRef : null; + if (memoRef) { + await this.recordMemoAssociation(record, result.transactionHash, memoRef); + } else { + console.warn( + `[StellarRecovery] No snippet memo reference returned for ${record.tx_type}; skipping snippet association`, + ); + } + await appendActivityLog("stellar.tx.submitted", "snippet", { resourceId: payload.snippetId as string | null, metadata: { @@ -223,6 +238,7 @@ export class StellarRecoveryService { id: record.id, stellarLedger: confirmed, }); + await this.confirmMemoAssociation(result.transactionHash, confirmed); await appendActivityLog("stellar.tx.confirmed", "snippet", { resourceId: payload.snippetId as string | null, @@ -519,4 +535,159 @@ export class StellarRecoveryService { ): Promise { return this.repo.findBySnippetId(snippetId); } + + /** + * Transaction ↔ snippet associations recorded from Stellar memos. + * Powers snippet traceability: the history of anchored, licensed or + * transferred transactions for a snippet. + */ + async getSnippetTransactions(snippetId: string): Promise { + return this.snippetTransactions.findBySnippetId(snippetId); + } + + /** + * Persist the memo → snippet association right after submission. + * + * Best-effort: association failures are logged and never abort the + * transaction flow, and nothing is written when the memo reference is + * missing, so no orphaned association can be created. + */ + private async recordMemoAssociation( + record: PendingStellarTransaction, + stellarTxHash: string, + memoRef: string, + ): Promise { + const snippetIds = collectSnippetIds(record.payload); + + if (snippetIds.length === 0) { + console.warn( + `[StellarRecovery] No snippet id found in ${record.tx_type} payload; skipping memo association for ${stellarTxHash}`, + ); + return; + } + + for (const snippetId of snippetIds) { + try { + await this.snippetTransactions.linkPending({ + snippetId, + transactionHash: stellarTxHash, + memoRef, + txType: record.tx_type, + }); + + await appendActivityLog("snippet.transaction.linked", "snippet", { + resourceId: snippetId, + metadata: { + memoRef, + stellarTxHash, + txType: record.tx_type, + status: "pending", + }, + }); + } catch (error) { + console.warn( + `[StellarRecovery] Failed to associate ${stellarTxHash} with snippet ${snippetId}:`, + error instanceof Error ? error.message : error, + ); + } + } + + // Audit the memo attachment (reference only — never the secret payload). + await appendActivityLog("stellar.memo.attached", "snippet", { + resourceId: snippetIds[0], + metadata: { + memoRef, + stellarTxHash, + txType: record.tx_type, + snippetIds, + }, + }); + } + + /** Flip pending associations to confirmed once the ledger holds the tx. */ + private async confirmMemoAssociation( + stellarTxHash: string, + ledgerSequence: number, + ): Promise { + try { + await this.snippetTransactions.markConfirmed({ + transactionHash: stellarTxHash, + ledgerSequence, + }); + + await appendActivityLog("snippet.transaction.confirmed", "snippet", { + resourceId: null, + metadata: { stellarTxHash, ledgerSequence }, + }); + } catch (error) { + console.warn( + `[StellarRecovery] Failed to confirm snippet association for ${stellarTxHash}:`, + error instanceof Error ? error.message : error, + ); + } + } + + /** + * Mark an existing association as failed when a transaction does not make + * it through. Records that never produced a hash were never associated, so + * there is nothing to clean up. + */ + private async failMemoAssociation( + record: PendingStellarTransaction, + error: string, + ): Promise { + if (!record.stellar_tx_hash) { + return; + } + + try { + await this.snippetTransactions.markFailed({ + transactionHash: record.stellar_tx_hash, + errorMessage: error, + }); + + await appendActivityLog("snippet.transaction.failed", "snippet", { + resourceId: null, + metadata: { + stellarTxHash: record.stellar_tx_hash, + txType: record.tx_type, + error, + }, + }); + } catch (associationError) { + console.warn( + `[StellarRecovery] Failed to mark snippet association failed for ${record.stellar_tx_hash}:`, + associationError instanceof Error ? associationError.message : associationError, + ); + } + } +} + +/** + * Snippet ids referenced by a pending transaction payload. + * Individual transactions carry `snippetId`; batch transactions carry + * `snippets: [{ id, hash }]`. + */ +function collectSnippetIds(payload: Record): string[] { + if (typeof payload.snippetId === "string" && payload.snippetId.length > 0) { + return [payload.snippetId]; + } + + const batch = payload.snippets; + if (Array.isArray(batch)) { + return batch + .map((entry) => { + if ( + entry && + typeof entry === "object" && + typeof (entry as { id?: unknown }).id === "string" + ) { + return (entry as { id: string }).id; + } + return null; + }) + .filter((id): id is string => Boolean(id)); + } + + return []; } diff --git a/lib/stellar.ts b/lib/stellar.ts index 6c1e404..fde9b2d 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -6,6 +6,11 @@ import { StellarTransactionConfirmationService, } from "@/lib/transaction-confirmation.service"; import { appendActivityLog } from "@/lib/activity-logger"; +import { + buildBatchSnippetMemo, + buildSnippetMemo, + generateSnippetMemoRef, +} from "@/lib/snippet-memo"; const STELLAR_NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK || "testnet"; const STELLAR_SECRET_KEY = process.env.STELLAR_SECRET_KEY || ""; @@ -26,6 +31,8 @@ export interface StellarSubmitResult { ledger?: number; timestamp?: string; memo?: string; + /** Snippet memo reference attached to the transaction memo, when applicable. */ + memoRef?: string; error?: string; /** Added by confirmation flow integration */ lifecycle?: string; @@ -138,8 +145,9 @@ export async function submitTransactionWithConfirmation({ /** * Submit an immutable ownership-transfer memo/proof on Stellar. - * Memo format (truncated/compacted to Stellar memo_text length limits): - * `tr:::` + * The memo carries the unique snippet reference (`snip:`); the owner + * pair travels in the manageData entry so the memo stays inside Stellar's + * 28-byte memo_text limit without truncation. */ export async function submitOwnershipTransferMemoToStellar({ secretKey, @@ -157,7 +165,7 @@ export async function submitOwnershipTransferMemoToStellar({ // Fall back to deterministic mock when no key configured. if (!key) { const timestamp = new Date().toISOString(); - const memo = buildOwnershipTransferMemo(snippetId, oldOwnerWalletAddress, newOwnerWalletAddress); + const memo = buildSnippetMemo(snippetId); const txHash = crypto .createHash("sha256") .update(`${snippetId}:${oldOwnerWalletAddress}:${newOwnerWalletAddress}:${timestamp}`) @@ -172,6 +180,7 @@ export async function submitOwnershipTransferMemoToStellar({ transactionHash: txHash, timestamp, memo, + memoRef: generateSnippetMemoRef(snippetId), }; } @@ -181,11 +190,7 @@ export async function submitOwnershipTransferMemoToStellar({ const account = await server.loadAccount(keypair.publicKey()); const timestamp = new Date().toISOString(); - const memoText = buildOwnershipTransferMemo( - snippetId, - oldOwnerWalletAddress, - newOwnerWalletAddress, - ); + const memoText = buildSnippetMemo(snippetId); const transaction = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, @@ -211,6 +216,7 @@ export async function submitOwnershipTransferMemoToStellar({ ledger: response.ledger, timestamp, memo: memoText, + memoRef: generateSnippetMemoRef(snippetId), }; } catch (error: any) { console.error("[Stellar] Ownership transfer submission failed:", error?.message); @@ -225,8 +231,9 @@ export async function submitOwnershipTransferMemoToStellar({ /** * Submit a snippet hash + creation timestamp to the Stellar blockchain. - * The memo encodes: "snip:::" - * truncated to 28 bytes to fit Stellar's memo_text limit. + * The memo carries the snippet reference: "snip::" (26 bytes + * max), validated against Stellar's 28-byte memo_text limit — never + * truncated. * * Immutability guarantee: once the transaction is confirmed on-chain, * the hash and timestamp are permanently anchored and cannot be altered. @@ -249,9 +256,8 @@ export async function submitHashToStellar( const keypair = StellarSdk.Keypair.fromSecret(key); const account = await server.loadAccount(keypair.publicKey()); - // Build a compact memo: first 28 chars of "snip::" const timestamp = createdAt || new Date().toISOString(); - const memoText = buildMemo(snippetId, contentHash, timestamp); + const memoText = buildSnippetMemo(snippetId, contentHash); const transaction = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, @@ -277,6 +283,7 @@ export async function submitHashToStellar( ledger: response.ledger, timestamp, memo: memoText, + memoRef: generateSnippetMemoRef(snippetId), }; } catch (error: any) { console.error("[Stellar] Transaction submission failed:", error?.message); @@ -297,8 +304,9 @@ export async function submitHashToStellar( /** * Submit a batch of snippet hashes in a single Stellar transaction. - * The memo contains the batch hash; individual hashes are stored as - * manageData operations (up to 64 entries per transaction). + * The memo carries a unique batch reference (`snipb:`) that links every + * snippet in the batch; individual hashes are stored as manageData + * operations (up to 64 entries per transaction). */ export async function submitBatchHashToStellar( secretKey: string, @@ -324,7 +332,7 @@ export async function submitBatchHashToStellar( const batchHash = generateBatchHash(batch.map((s) => s.hash)); const timestamp = new Date().toISOString(); - const memoText = `batch:${batchHash.slice(0, 22)}`; + const memoText = buildBatchSnippetMemo(batchHash); const builder = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, @@ -351,6 +359,7 @@ export async function submitBatchHashToStellar( ledger: response.ledger, timestamp, memo: memoText, + memoRef: memoText, }; } catch (error: any) { console.error("[Stellar] Batch submission failed:", error?.message); @@ -445,37 +454,11 @@ export function classifyStellarError(error: string): { // ─── Helpers ──────────────────────────────────────────────────────────────── -/** - * Build a Stellar memo_text (max 28 bytes). - * Format: "s:<8-char-id>:<8-char-hash>" - */ -function buildMemo( - snippetId: string, - contentHash: string, - _timestamp: string, -): string { - const shortId = snippetId.replace(/-/g, "").slice(0, 8); - const shortHash = contentHash.slice(0, 8); - return `s:${shortId}:${shortHash}`; -} - function generateBatchHash(hashes: string[]): string { const combined = [...hashes].sort().join("|"); return crypto.createHash("sha256").update(combined).digest("hex"); } -function buildOwnershipTransferMemo( - snippetId: string, - oldOwnerWalletAddress: string, - newOwnerWalletAddress: string, -): string { - const shortSnippet = snippetId.replace(/-/g, "").slice(0, 8); - const shortOld = oldOwnerWalletAddress.slice(0, 8); - const shortNew = newOwnerWalletAddress.slice(0, 8); - // Stellar memo_text max length is 28 bytes; this stays compact. - return `tr:${shortSnippet}:${shortOld}:${shortNew}`.slice(0, 28); -} - // ─── Mock fallbacks (no secret key configured) ────────────────────────────── @@ -494,11 +477,14 @@ function mockStellarSubmit( "[Stellar] No secret key configured — using deterministic mock transaction.", ); + const memo = buildSnippetMemo(snippetId, contentHash); + return { success: true, transactionHash: txHash, timestamp, - memo: buildMemo(snippetId, contentHash, timestamp), + memo, + memoRef: generateSnippetMemoRef(snippetId), }; } @@ -514,11 +500,14 @@ function mockBatchStellarSubmit( "[Stellar] No secret key configured — using deterministic mock batch transaction.", ); + const memo = buildBatchSnippetMemo(batchHash); + return { success: true, transactionHash: txHash, timestamp, - memo: `batch:${batchHash.slice(0, 22)}`, + memo, + memoRef: memo, }; } @@ -540,7 +529,7 @@ export async function mintSnippetLicenseOnStellar({ if (!key) { const timestamp = new Date().toISOString(); - const memo = `lic:${snippetId.slice(0, 8)}`.slice(0, 28); + const memo = buildSnippetMemo(snippetId); const txHash = crypto .createHash("sha256") .update(`${snippetId}:${licenseType}:${ownerWalletAddress}:${timestamp}`) @@ -555,6 +544,7 @@ export async function mintSnippetLicenseOnStellar({ transactionHash: txHash, timestamp, memo, + memoRef: generateSnippetMemoRef(snippetId), }; } @@ -564,7 +554,7 @@ export async function mintSnippetLicenseOnStellar({ const account = await server.loadAccount(keypair.publicKey()); const timestamp = new Date().toISOString(); - const memoText = `lic:${snippetId.replace(/-/g, "").slice(0, 8)}`.slice(0, 28); + const memoText = buildSnippetMemo(snippetId); const transaction = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, @@ -590,6 +580,7 @@ export async function mintSnippetLicenseOnStellar({ ledger: response.ledger, timestamp, memo: memoText, + memoRef: generateSnippetMemoRef(snippetId), }; } catch (error: any) { console.error("[Stellar] License minting failed:", error?.message); diff --git a/scripts/add-snippet-transactions.sql b/scripts/add-snippet-transactions.sql new file mode 100644 index 0000000..11eeb1a --- /dev/null +++ b/scripts/add-snippet-transactions.sql @@ -0,0 +1,34 @@ +-- Snippet ↔ Stellar transaction associations. +-- Every transaction that carries a snippet memo reference gets one row per +-- referenced snippet, so a snippet's history can be reconstructed from chain data. + +CREATE TABLE IF NOT EXISTS snippet_transactions ( + id UUID PRIMARY KEY, + snippet_id VARCHAR(64) NOT NULL, + transaction_hash VARCHAR(64) NOT NULL, + memo_ref VARCHAR(64) NOT NULL, + ledger_sequence BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed', 'failed')), + tx_type VARCHAR(50), + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- A transaction can only be linked to the same snippet once. + CONSTRAINT snippet_transactions_snippet_tx_unique UNIQUE (snippet_id, transaction_hash) +); + +-- Snippet traceability: list every transaction that references a snippet. +CREATE INDEX IF NOT EXISTS idx_snippet_transactions_snippet + ON snippet_transactions(snippet_id, created_at DESC); + +-- Reverse lookup: resolve the snippet(s) a confirmed transaction memo points at. +CREATE INDEX IF NOT EXISTS idx_snippet_transactions_memo_ref + ON snippet_transactions(memo_ref); + +-- Confirmation tracking / reconciliation. +CREATE INDEX IF NOT EXISTS idx_snippet_transactions_status + ON snippet_transactions(status); + +CREATE INDEX IF NOT EXISTS idx_snippet_transactions_hash + ON snippet_transactions(transaction_hash);