From 6843db09703db4266c6dfb202efffd8fe63e2588 Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Mon, 19 Oct 2026 19:40:32 +0100 Subject: [PATCH 1/4] fix: validate Idempotency-Key header (#72) --- src/lib/idempotencyKey.test.ts | 22 ++++++++++++++++++++++ src/lib/idempotencyKey.ts | 21 +++++++++++++++++++++ src/routes/resources.ts | 3 ++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/lib/idempotencyKey.test.ts create mode 100644 src/lib/idempotencyKey.ts diff --git a/src/lib/idempotencyKey.test.ts b/src/lib/idempotencyKey.test.ts new file mode 100644 index 0000000..353ef52 --- /dev/null +++ b/src/lib/idempotencyKey.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { parseIdempotencyKey } from "./idempotencyKey.js"; + +describe("parseIdempotencyKey", () => { + it("returns undefined when the header is absent", () => { + expect(parseIdempotencyKey(undefined)).toBeUndefined(); + }); + + it("accepts valid keys", () => { + expect(parseIdempotencyKey("abc_DEF-123")).toBe("abc_DEF-123"); + expect(parseIdempotencyKey("a".repeat(255))).toBe("a".repeat(255)); + }); + + it("rejects a 300-character key", () => { + expect(() => parseIdempotencyKey("a".repeat(300))).toThrow(/Idempotency-Key/); + }); + + it("rejects empty keys and invalid characters", () => { + expect(() => parseIdempotencyKey("")).toThrow(); + expect(() => parseIdempotencyKey("bad key!")).toThrow(); + }); +}); diff --git a/src/lib/idempotencyKey.ts b/src/lib/idempotencyKey.ts new file mode 100644 index 0000000..7c41c00 --- /dev/null +++ b/src/lib/idempotencyKey.ts @@ -0,0 +1,21 @@ +import { AppError } from "./errors.js"; + +// Idempotency-Key format (#72): 1 to 255 characters from [A-Za-z0-9_-]. +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{1,255}$/; + +export function isValidIdempotencyKey(key: string): boolean { + return IDEMPOTENCY_KEY_PATTERN.test(key); +} + +// Returns the validated key, or undefined when the header is absent. +// Throws a 400 VALIDATION_ERROR when the header is present but malformed. +export function parseIdempotencyKey(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + if (!isValidIdempotencyKey(raw)) { + throw new AppError( + "VALIDATION_ERROR", + "Invalid Idempotency-Key: must be 1-255 characters from [A-Za-z0-9_-]", + ); + } + return raw; +} diff --git a/src/routes/resources.ts b/src/routes/resources.ts index 177c405..e9ec752 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -34,6 +34,7 @@ import { config } from "../config.js"; import { getLogger } from "../lib/logger.js"; import { publishIpRateLimit, publishWalletRateLimit } from "../middleware/rateLimiters.js"; import { getIdempotencyStore, idempotencyCacheKey } from "../lib/idempotency.js"; +import { parseIdempotencyKey } from "../lib/idempotencyKey.js"; import { buildRegistrationFailureGuidance, explorerTxUrl } from "../lib/registrationGuidance.js"; import { NETWORK_PASSPHRASE, @@ -82,7 +83,7 @@ router.post( // Idempotency (#114): if the client supplies an Idempotency-Key, a retry // with the same key returns the original result instead of creating a // duplicate. Keys are scoped per publisher. - const idemKey = req.header("Idempotency-Key"); + const idemKey = parseIdempotencyKey(req.header("Idempotency-Key")); const store = getIdempotencyStore(); const scopedKey = idemKey ? idempotencyCacheKey(publisher.id, idemKey) : null; From 0ec92b9404084e5eb1ef4963965c2656001dcd7b Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Mon, 19 Oct 2026 19:41:12 +0100 Subject: [PATCH 2/4] feat(idempotency): store records in Postgres so replicas share them (#71) --- drizzle/0011_idempotency_records.sql | 7 +++ drizzle/meta/_journal.json | 7 +++ src/db/schema.ts | 8 +++ src/index.ts | 13 +++++ src/lib/idempotency.ts | 13 ++++- src/lib/idempotencyPgStore.test.ts | 52 +++++++++++++++++++ src/lib/idempotencyPgStore.ts | 77 ++++++++++++++++++++++++++++ src/routes/resources.ts | 18 +++---- 8 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 drizzle/0011_idempotency_records.sql create mode 100644 src/lib/idempotencyPgStore.test.ts create mode 100644 src/lib/idempotencyPgStore.ts diff --git a/drizzle/0011_idempotency_records.sql b/drizzle/0011_idempotency_records.sql new file mode 100644 index 0000000..2ae829e --- /dev/null +++ b/drizzle/0011_idempotency_records.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "idempotency_records" ( + "key" text PRIMARY KEY NOT NULL, + "record" jsonb NOT NULL, + "expires_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_idempotency_records_expires_at" ON "idempotency_records" ("expires_at"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1ad7df2..40d6cba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1780300000000, "tag": "0010_admin_delisted_at", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1780400000000, + "tag": "0011_idempotency_records", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 6c58ba4..3f7eb35 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,6 +7,7 @@ import { timestamp, pgEnum, index, + jsonb, } from "drizzle-orm/pg-core"; import { createId } from "@paralleldrive/cuid2"; @@ -232,3 +233,10 @@ export const ledgerCheckpoints = pgTable("ledger_checkpoints", { lastLedger: integer("last_ledger").notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); + +// Idempotency records shared across replicas (#71). +export const idempotencyRecords = pgTable("idempotency_records", { + key: text("key").primaryKey(), + record: jsonb("record").notNull(), + expiresAt: timestamp("expires_at").notNull(), +}); diff --git a/src/index.ts b/src/index.ts index 4866146..90830a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,22 @@ import { startPoolMetrics, stopPoolMetrics } from "./db/client.js"; import { startRetryPendingWorker, stopRetryPendingWorker } from "./workers/retryPendingWorker.js"; import { startEventListener, stopEventListener } from "./workers/eventListener.js"; import { closeRateLimitStore } from "./lib/rateLimit/index.js"; +import { useSharedIdempotencyStore } from "./lib/idempotency.js"; +import { + createPostgresRowStore, + createSharedIdempotencyStore, + type PgSql, +} from "./lib/idempotencyPgStore.js"; initSentry(); +// Share idempotency records across replicas via Postgres (#71). +useSharedIdempotencyStore( + createSharedIdempotencyStore(createPostgresRowStore(pgClient as unknown as PgSql), { + defaultTtlMs: config.IDEMPOTENCY_TTL_MS, + }), +); + const app = createApp(); // Advisory lock key used to elect a single leader across replicas. Only the diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index f626991..777cd57 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -29,6 +29,17 @@ const defaultStore = createTtlCache({ cacheName: "idempotency", }); +// Shared (Postgres-backed) store installed at boot so retries that land on a +// different replica see the original result (#71). When unset (tests, local +// scripts) the in-memory store above is used. +let sharedStore: unknown; + +export function useSharedIdempotencyStore(store: unknown): void { + sharedStore = store; +} + +// Typed as TtlCache for existing callers; the shared store has the same +// methods but returns promises, so route code must `await` store calls. export function getIdempotencyStore(): TtlCache { - return defaultStore; + return (sharedStore ?? defaultStore) as TtlCache; } diff --git a/src/lib/idempotencyPgStore.test.ts b/src/lib/idempotencyPgStore.test.ts new file mode 100644 index 0000000..098ffc0 --- /dev/null +++ b/src/lib/idempotencyPgStore.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { + createSharedIdempotencyStore, + type IdempotencyRow, + type IdempotencyRowStore, +} from "./idempotencyPgStore.js"; + +// A fake "database" shared by several store instances (simulating replicas). +function fakeDatabase(): IdempotencyRowStore { + const table = new Map(); + return { + async select(key) { + const row = table.get(key); + return row ? structuredClone(row) : undefined; + }, + async upsert(key, record, expiresAt) { + table.set(key, { record: structuredClone(record), expiresAt }); + }, + async remove(key) { + table.delete(key); + }, + async removeAll() { + table.clear(); + }, + }; +} + +describe("shared idempotency store", () => { + it("returns the original result on retry from another instance", async () => { + const db = fakeDatabase(); + const a = createSharedIdempotencyStore(db, { defaultTtlMs: 60_000 }); + const b = createSharedIdempotencyStore(db, { defaultTtlMs: 60_000 }); + const result = { status: 201, body: { id: "r1" } }; + + await a.set("pub:k", { inProgress: true }); + expect(await b.get("pub:k")).toEqual({ inProgress: true }); + await a.set("pub:k", { inProgress: false, result }); + expect(await b.get("pub:k")).toEqual({ inProgress: false, result }); + }); + + it("expires records and supports delete", async () => { + let t = 1_000; + const db = fakeDatabase(); + const s = createSharedIdempotencyStore(db, { defaultTtlMs: 100, now: () => t }); + await s.set("k", { inProgress: true }); + t += 200; + expect(await s.get("k")).toBeUndefined(); + await s.set("k2", { inProgress: true }); + await s.delete("k2"); + expect(await s.get("k2")).toBeUndefined(); + }); +}); diff --git a/src/lib/idempotencyPgStore.ts b/src/lib/idempotencyPgStore.ts new file mode 100644 index 0000000..9008e09 --- /dev/null +++ b/src/lib/idempotencyPgStore.ts @@ -0,0 +1,77 @@ +import type { IdempotencyRecord } from "./idempotency.js"; + +// Shared idempotency store (#71). Records live in the `idempotency_records` +// Postgres table (unique key, JSON record, expiry) so a retry that lands on a +// different replica sees the original result instead of creating a duplicate. +// The in-memory TtlCache remains the default (and is what tests use). + +export interface IdempotencyRow { + record: IdempotencyRecord; + expiresAt: Date; +} + +// Thin persistence seam so the store logic can be tested against a shared fake. +export interface IdempotencyRowStore { + select(key: string): Promise; + upsert(key: string, record: IdempotencyRecord, expiresAt: Date): Promise; + remove(key: string): Promise; + removeAll(): Promise; +} + +// Minimal shape of the postgres.js tagged-template client. +export type PgSql = (strings: TemplateStringsArray, ...values: any[]) => Promise; + +export function createPostgresRowStore(sql: PgSql): IdempotencyRowStore { + return { + async select(key) { + const rows = await sql`SELECT record, expires_at FROM idempotency_records WHERE key = ${key}`; + const row = rows[0]; + if (!row) return undefined; + const record = + typeof row.record === "string" ? (JSON.parse(row.record) as IdempotencyRecord) : row.record; + return { record, expiresAt: new Date(row.expires_at) }; + }, + async upsert(key, record, expiresAt) { + await sql` + INSERT INTO idempotency_records (key, record, expires_at) + VALUES (${key}, ${JSON.stringify(record)}::jsonb, ${expiresAt}) + ON CONFLICT (key) DO UPDATE SET record = EXCLUDED.record, expires_at = EXCLUDED.expires_at`; + }, + async remove(key) { + await sql`DELETE FROM idempotency_records WHERE key = ${key}`; + }, + async removeAll() { + await sql`DELETE FROM idempotency_records`; + }, + }; +} + +export interface SharedIdempotencyStore { + get(key: string): Promise; + set(key: string, value: IdempotencyRecord, ttlMs?: number): Promise; + delete(key: string): Promise; + clear(): Promise; +} + +export function createSharedIdempotencyStore( + rows: IdempotencyRowStore, + options: { defaultTtlMs: number; now?: () => number }, +): SharedIdempotencyStore { + const now = options.now ?? Date.now; + return { + async get(key) { + const row = await rows.select(key); + if (!row) return undefined; + if (now() >= row.expiresAt.getTime()) { + await rows.remove(key); + return undefined; + } + return row.record; + }, + async set(key, value, ttlMs) { + await rows.upsert(key, value, new Date(now() + (ttlMs ?? options.defaultTtlMs))); + }, + delete: (key) => rows.remove(key), + clear: () => rows.removeAll(), + }; +} diff --git a/src/routes/resources.ts b/src/routes/resources.ts index e9ec752..0b05ece 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -88,7 +88,7 @@ router.post( const scopedKey = idemKey ? idempotencyCacheKey(publisher.id, idemKey) : null; if (scopedKey) { - const existing = store.get(scopedKey); + const existing = await store.get(scopedKey); if (existing) { if (existing.inProgress) { // A concurrent request with the same key is still running. @@ -97,12 +97,12 @@ router.post( res.status(existing.result.status).json(existing.result.body); return; } - store.set(scopedKey, { inProgress: true }); + await store.set(scopedKey, { inProgress: true }); } // Records the final result under the idempotency key (when keyed), then responds. - const sendResult = (status: number, body: unknown) => { - if (scopedKey) store.set(scopedKey, { inProgress: false, result: { status, body } }); + const sendResult = async (status: number, body: unknown) => { + if (scopedKey) await store.set(scopedKey, { inProgress: false, result: { status, body } }); res.status(status).json(body); }; @@ -113,7 +113,7 @@ router.post( if (!parsed.success) { // Validation failures aren't a committed result — release the key so // a corrected retry can proceed. - if (scopedKey) store.delete(scopedKey); + if (scopedKey) await store.delete(scopedKey); throw new AppError("VALIDATION_ERROR", "Invalid request body", { detail: parsed.error.format(), }); @@ -137,7 +137,7 @@ router.post( mimeType: req.file.mimetype, }); - sendResult(201, { + await sendResult(201, { ...resource, accessUrl: `${config.BASE_URL}/resources/${resource.id}`, }); @@ -147,7 +147,7 @@ router.post( // Link resource const parsed = linkPublishSchema.safeParse(req.body); if (!parsed.success) { - if (scopedKey) store.delete(scopedKey); + if (scopedKey) await store.delete(scopedKey); throw new AppError("VALIDATION_ERROR", "Invalid request body", { detail: parsed.error.format(), }); @@ -162,14 +162,14 @@ router.post( externalUrl: parsed.data.externalUrl, }); - sendResult(201, { + await sendResult(201, { ...resource, accessUrl: `${config.BASE_URL}/resources/${resource.id}`, }); } catch (err) { // Publish failed — release the key so the client can retry rather than // being stuck behind a stale in-progress marker. - if (scopedKey) store.delete(scopedKey); + if (scopedKey) await store.delete(scopedKey); throw err; } finally { // Always remove the disk-stored temp file, whether the request succeeded From a881f980822c2fb3e0558f6de65ca379fad7f1aa Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Mon, 19 Oct 2026 19:41:38 +0100 Subject: [PATCH 3/4] fix(timeout): abort signal on request timeout and record final idempotency outcome (#73) --- src/lib/requestAbort.ts | 14 +++++++++ src/middleware/timeout.abort.test.ts | 43 ++++++++++++++++++++++++++++ src/middleware/timeout.ts | 13 +++++++++ src/routes/resources.ts | 16 ++++++++++- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/lib/requestAbort.ts create mode 100644 src/middleware/timeout.abort.test.ts diff --git a/src/lib/requestAbort.ts b/src/lib/requestAbort.ts new file mode 100644 index 0000000..3901cbe --- /dev/null +++ b/src/lib/requestAbort.ts @@ -0,0 +1,14 @@ +import type { Response } from "express"; + +// Per-request AbortSignal (#73). The timeout middleware aborts it when the +// request times out so handlers can stop starting new work (storage writes, +// database inserts) for a request the client has already been told failed. +const KEY = "requestAbortSignal"; + +export function setRequestSignal(res: Response, signal: AbortSignal): void { + (res.locals ??= {})[KEY] = signal; +} + +export function getRequestSignal(res: Response): AbortSignal | undefined { + return res.locals?.[KEY] as AbortSignal | undefined; +} diff --git a/src/middleware/timeout.abort.test.ts b/src/middleware/timeout.abort.test.ts new file mode 100644 index 0000000..a96b082 --- /dev/null +++ b/src/middleware/timeout.abort.test.ts @@ -0,0 +1,43 @@ +import type { Request, Response } from "express"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { requestTimeout } from "./timeout.js"; +import { getRequestSignal } from "../lib/requestAbort.js"; +import { createTtlCache } from "../lib/ttlCache.js"; +import type { IdempotencyRecord } from "../lib/idempotency.js"; + +describe("requestTimeout abort signal (#73)", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("aborts the signal on timeout and the idempotency record still ends final", async () => { + const res = { + headersSent: false, + locals: {}, + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + on: vi.fn(), + } as unknown as Response; + const req = { method: "POST", originalUrl: "/resources", url: "/resources" } as Request; + + const store = createTtlCache({ defaultTtlMs: 60_000 }); + store.set("p:k", { inProgress: true }); + + requestTimeout(100)(req, res, vi.fn()); + const signal = getRequestSignal(res)!; + expect(signal.aborted).toBe(false); + + vi.advanceTimersByTime(100); + expect(signal.aborted).toBe(true); + expect(res.status).toHaveBeenCalledWith(503); + + // Handler finishes after the timeout: records the final outcome and skips + // writing to the already-answered response. + (res as any).headersSent = true; + store.set("p:k", { inProgress: false, result: { status: 201, body: { id: "r1" } } }); + expect(store.get("p:k")).toEqual({ + inProgress: false, + result: { status: 201, body: { id: "r1" } }, + }); + }); +}); diff --git a/src/middleware/timeout.ts b/src/middleware/timeout.ts index 04206a2..fb809b3 100644 --- a/src/middleware/timeout.ts +++ b/src/middleware/timeout.ts @@ -1,14 +1,27 @@ import type { NextFunction, Request, Response } from "express"; import { getLogger } from "../lib/logger.js"; import { getRequestLogContext } from "../lib/requestLogContext.js"; +import { setRequestSignal } from "../lib/requestAbort.js"; // Per-request timeout (issue #113). When a slow upstream (RPC, facilitator, // storage) keeps a request open past the limit, respond 503 instead of letting // the connection hang. The timer is cleared once the response finishes (or the // client disconnects) so completed requests never trip it. +// +// Abort semantics (#73): on timeout the request's AbortSignal (see +// lib/requestAbort.ts) is aborted. Handlers check it before starting new work, +// so a publish that has not yet created anything is cancelled and its +// idempotency key released. Work that is already past that point is not +// interrupted: it completes, records its final outcome under the idempotency +// key (so a retry returns the committed result rather than a stale +// "in progress" 409), and skips writing to the already-answered response. export function requestTimeout(ms: number) { return (req: Request, res: Response, next: NextFunction): void => { + const controller = new AbortController(); + setRequestSignal(res, controller.signal); + const timer = setTimeout(() => { + controller.abort(); // Headers may already be on the wire (e.g. a streamed download in // progress) — don't try to rewrite the response in that case. if (res.headersSent) return; diff --git a/src/routes/resources.ts b/src/routes/resources.ts index 0b05ece..fdb43f4 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -35,6 +35,7 @@ import { getLogger } from "../lib/logger.js"; import { publishIpRateLimit, publishWalletRateLimit } from "../middleware/rateLimiters.js"; import { getIdempotencyStore, idempotencyCacheKey } from "../lib/idempotency.js"; import { parseIdempotencyKey } from "../lib/idempotencyKey.js"; +import { getRequestSignal } from "../lib/requestAbort.js"; import { buildRegistrationFailureGuidance, explorerTxUrl } from "../lib/registrationGuidance.js"; import { NETWORK_PASSPHRASE, @@ -103,7 +104,9 @@ router.post( // Records the final result under the idempotency key (when keyed), then responds. const sendResult = async (status: number, body: unknown) => { if (scopedKey) await store.set(scopedKey, { inProgress: false, result: { status, body } }); - res.status(status).json(body); + // After a request timeout (#73) the 503 is already sent; the final + // outcome is still recorded above so retries see it. + if (!res.headersSent) res.status(status).json(body); }; try { @@ -124,6 +127,11 @@ router.post( // Uploads are streamed to disk by multer (#90); read the temp file // back into a buffer for storage. Thumbnail generation now happens // asynchronously in a worker (#89), so the response does not wait on it. + if (getRequestSignal(res)?.aborted) { + // Timed out before any work started (#73): release the key. + if (scopedKey) await store.delete(scopedKey); + return; + } const fileBuffer = await fs.readFile(req.file.path); const resource = await createFileResource({ @@ -153,6 +161,12 @@ router.post( }); } + if (getRequestSignal(res)?.aborted) { + // Timed out before any work started (#73): release the key. + if (scopedKey) await store.delete(scopedKey); + return; + } + const resource = await createLinkResource({ publisherId: publisher.id, title: parsed.data.title, From 071df0f263dcf3fe4ca38d8710c703e9c967a1fb Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Mon, 19 Oct 2026 19:42:14 +0100 Subject: [PATCH 4/4] fix(resources): take price and new owner from the signed transaction (#74) --- src/routes/resources.ts | 11 +++++- src/services/signedTxDecode.test.ts | 37 +++++++++++++++++++ src/services/signedTxDecode.ts | 57 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/services/signedTxDecode.test.ts create mode 100644 src/services/signedTxDecode.ts diff --git a/src/routes/resources.ts b/src/routes/resources.ts index fdb43f4..c4ffdba 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -35,6 +35,7 @@ import { getLogger } from "../lib/logger.js"; import { publishIpRateLimit, publishWalletRateLimit } from "../middleware/rateLimiters.js"; import { getIdempotencyStore, idempotencyCacheKey } from "../lib/idempotency.js"; import { parseIdempotencyKey } from "../lib/idempotencyKey.js"; +import { decodeNewCreator, decodeSetPrice } from "../services/signedTxDecode.js"; import { getRequestSignal } from "../lib/requestAbort.js"; import { buildRegistrationFailureGuidance, explorerTxUrl } from "../lib/registrationGuidance.js"; import { @@ -797,6 +798,9 @@ router.post( }); } + // Take the price from the signed operation; reject a body that disagrees (#74). + const onChainPrice = decodeSetPrice(signedXdr, price); + const result = await submitSignedTx(signedXdr, expected); if (result.status === "failed") { @@ -815,7 +819,7 @@ router.post( // Sync the DB price to match the on-chain value const [updated] = await db .update(resources) - .set({ price }) + .set({ price: onChainPrice }) .where(eq(resources.id, resourceId)) .returning(); @@ -881,6 +885,9 @@ router.post( }); } + // Take the new owner from the signed operation; reject a mismatch (#74). + const onChainCreator = decodeNewCreator(signedXdr, newCreator); + const result = await submitSignedTx(signedXdr, expected); if (result.status === "failed") { @@ -898,7 +905,7 @@ router.post( const [updated] = await db .update(resources) - .set({ walletAddress: newCreator }) + .set({ walletAddress: onChainCreator }) .where(eq(resources.id, resourceId)) .returning(); diff --git a/src/services/signedTxDecode.test.ts b/src/services/signedTxDecode.test.ts new file mode 100644 index 0000000..b267d13 --- /dev/null +++ b/src/services/signedTxDecode.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("./registryClient.js", () => ({ NETWORK_PASSPHRASE: "Test SDF Network ; September 2015" })); + +import { Account, Address, Contract, Keypair, TransactionBuilder, nativeToScVal } from "@stellar/stellar-sdk"; +import { decodeNewCreator, decodeSetPrice } from "./signedTxDecode.js"; + +const PASS = "Test SDF Network ; September 2015"; +const kp = Keypair.random(); +const contractId = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + +function build(fn: string, ...args: any[]): string { + const tx = new TransactionBuilder(new Account(kp.publicKey(), "1"), { fee: "100", networkPassphrase: PASS }) + .addOperation(new Contract(contractId).call(fn, ...args)) + .setTimeout(30) + .build(); + return tx.toXDR(); +} + +describe("signed tx decoding (#74)", () => { + const priceXdr = build("set_price", nativeToScVal("r1", { type: "string" }), nativeToScVal(5_000_000n, { type: "i128" })); + + it("returns the on-chain price when the body matches", () => { + expect(decodeSetPrice(priceXdr, "0.5")).toBe("0.5"); + }); + + it("rejects a price that differs from the signed operation", () => { + expect(() => decodeSetPrice(priceXdr, "9")).toThrow(/price/); + }); + + it("checks the new owner", () => { + const owner = Keypair.random().publicKey(); + const xdr = build("transfer_ownership", nativeToScVal("r1", { type: "string" }), new Address(owner).toScVal()); + expect(decodeNewCreator(xdr, owner)).toBe(owner); + expect(() => decodeNewCreator(xdr, Keypair.random().publicKey())).toThrow(/newCreator/); + }); +}); diff --git a/src/services/signedTxDecode.ts b/src/services/signedTxDecode.ts new file mode 100644 index 0000000..d895573 --- /dev/null +++ b/src/services/signedTxDecode.ts @@ -0,0 +1,57 @@ +import { TransactionBuilder, scValToNative, type xdr } from "@stellar/stellar-sdk"; +import { AppError } from "../lib/errors.js"; +import { NETWORK_PASSPHRASE } from "./registryClient.js"; +import { stroopsToUsdc, usdcToStroops } from "../utils/usdc.js"; + +// Decodes the registry invocation arguments from a signed transaction (#74) so +// routes can take price / new owner from the signed operation instead of the +// request body. Call after validateSignedTx has checked contract and function. +export function decodeInvokeArgs(signedXdr: string): unknown[] { + try { + const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); + const op = (tx as any).operations[0]; + const hostFn = op.func as xdr.HostFunction; + return hostFn.invokeContract().args().map((a: xdr.ScVal) => scValToNative(a)); + } catch (err) { + throw new AppError("VALIDATION_ERROR", "Could not decode signed transaction", { + detail: err instanceof Error ? err.message : String(err), + }); + } +} + +function mismatch(field: string): AppError { + return new AppError( + "VALIDATION_ERROR", + `Request ${field} does not match the signed transaction`, + ); +} + +// Returns the price (USDC decimal string) encoded in a signed set_price tx. +// Throws a 400 when `bodyPrice` disagrees with it. +export function decodeSetPrice(signedXdr: string, bodyPrice: string): string { + const args = decodeInvokeArgs(signedXdr); + const raw = args[1]; + if (typeof raw !== "bigint" && typeof raw !== "number") { + throw new AppError("VALIDATION_ERROR", "Signed transaction has no price argument"); + } + const onChain = BigInt(raw); + let requested: bigint; + try { + requested = usdcToStroops(bodyPrice); + } catch { + throw mismatch("price"); + } + if (requested !== onChain) throw mismatch("price"); + return stroopsToUsdc(onChain); +} + +// Returns the new owner address encoded in a signed transfer_ownership tx. +// Throws a 400 when `bodyNewCreator` disagrees with it. +export function decodeNewCreator(signedXdr: string, bodyNewCreator: string): string { + const raw = decodeInvokeArgs(signedXdr)[1]; + if (typeof raw !== "string" || raw.length === 0) { + throw new AppError("VALIDATION_ERROR", "Signed transaction has no new owner argument"); + } + if (raw !== bodyNewCreator) throw mismatch("newCreator"); + return raw; +}