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
17 changes: 16 additions & 1 deletion app/api/snippets/[id]/transaction-status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion lib/activity-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
166 changes: 166 additions & 0 deletions lib/snippet-memo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
134 changes: 134 additions & 0 deletions lib/snippet-memo.ts
Original file line number Diff line number Diff line change
@@ -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:<ref>` or `snip:<ref>:<hash8>`
* (at most 26 bytes). The optional hash suffix keeps the anchored-content hint
* the previous `s:<id8>:<hash8>` 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);
}
Loading