From c8e3f1082cdd14cab1d5f9da26315cb35dfab355 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Sun, 10 May 2026 20:22:54 -0700 Subject: [PATCH 1/2] feat(outbox): flip GET /api/outbox/[address] to D1 reads + restore sentCount/partners in inbox-list (refs #728, Phase 2.5 Step 3.3) Part A: flip GET /api/outbox/[address] from KV listInboxMessages (which loaded all inbox+reply records and extracted replies) to a direct D1 SELECT against inbox_messages WHERE is_reply=1 AND from_btc_address=?. Adds listOutboxRepliesFromD1 + countOutboxRepliesFromD1 helpers to lib/inbox/d1-reads.ts. Security gate: from_btc_address=? SQL predicate prevents cross-agent reply leakage. D1-throws fallback: 503+Retry-After:5 on transient D1 errors (matches #722/#731 shape). CACHE_INVARIANTS:POSTURE=public-only-get pointer preserved (1-line, no inline block). Part B: restores sentCount + partners dimensions in GET /api/inbox/[address] that were stubbed in #722 Step 3.1. sentCount now comes from countOutboxRepliesFromD1 added to the existing Promise.all. Partners graph merges both received (inbound senders) and sent (outbox reply targets) into the partner map before dedup/sort. Tests added: outbox GET 200+empty+tenant-discriminator+D1-throws+pagination, sentCount restoration, partners-with-sent, partners-received-only, D1-throws with extra parallel queries, d1-reads unit tests for both new helpers. Pre-existing og-d1.test.ts failure is on main and unrelated. Co-Authored-By: Claude --- .../__tests__/d1-sentcount-partners.test.ts | 387 ++++++++++++++++++ app/api/inbox/[address]/route.ts | 54 ++- .../[address]/__tests__/d1-reads-flip.test.ts | 243 +++++++++++ app/api/outbox/[address]/route.ts | 72 +++- lib/inbox/__tests__/d1-reads.test.ts | 154 +++++++ lib/inbox/d1-reads.ts | 71 ++++ 6 files changed, 958 insertions(+), 23 deletions(-) create mode 100644 app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts create mode 100644 app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts diff --git a/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts b/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts new file mode 100644 index 00000000..96647093 --- /dev/null +++ b/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts @@ -0,0 +1,387 @@ +/** + * Phase 2.5 Step 3.3 — sentCount restoration + partners-with-sent tests. + * + * Covers: + * 1. sentCount restoration: inbox-list GET returns sentCount > 0 when + * countOutboxRepliesFromD1 returns a positive value + * (was "const sentCount = 0" in Step 3.1) + * 2. partners-with-sent: partner graph includes both inbound senders (received) + * AND addresses this agent has replied to (sent) + * 3. partners-received-only: when no sent replies exist, partners still computed + * from received messages + * 4. D1-throws fallback: even with the extra countOutboxRepliesFromD1 in + * Promise.all, D1 throws still produce 503 + Retry-After: 5 + * + * See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +// ---- module mocks (must be declared before route imports) ------------------- + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +vi.mock("@/lib/agent-lookup", () => ({ + lookupAgent: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-reads", () => ({ + listInboxMessagesFromD1: vi.fn(), + countInboxMessagesFromD1: vi.fn(), + fetchRepliesForMessages: vi.fn(), + listOutboxRepliesFromD1: vi.fn(), + countOutboxRepliesFromD1: vi.fn(), +})); + +vi.mock("@/lib/cache", () => ({ + invalidateAgentListCache: vi.fn(), +})); + +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + isLogsRPC: () => false, +})); + +vi.mock("@/lib/inbox", () => ({ + validateInboxMessage: vi.fn(), + verifyInboxPayment: vi.fn(), + verifyTxidPayment: vi.fn(), + storeMessage: vi.fn(), + storeStagedInboxPayment: vi.fn(), + updateAgentInbox: vi.fn(), + updateSentIndex: vi.fn(), + INBOX_PRICE_SATS: 100, + REDEEMED_TXID_TTL_SECONDS: 7776000, + RELAY_CIRCUIT_BREAKER_RETRY_AFTER_SECONDS: 300, + buildInboxPaymentRequirements: vi.fn(), + buildSenderAuthMessage: vi.fn(), + DEFAULT_RELAY_URL: "https://x402-relay.aibtc.com", + enqueueInboxReconciliation: vi.fn(), +})); + +vi.mock("@/lib/bitcoin-verify", () => ({ + verifyBitcoinSignature: vi.fn(), +})); + +vi.mock("@/lib/inbox/payment-logging", () => ({ + getPaymentRepoVersion: vi.fn().mockReturnValue("1.0.0"), + logPaymentEvent: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-dual-write", () => ({ + insertInboundMessageToD1: vi.fn().mockResolvedValue(undefined), +})); + +// ---- imports after mocks ---------------------------------------------------- + +import { GET } from "../route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { lookupAgent } from "@/lib/agent-lookup"; +import { + listInboxMessagesFromD1, + countInboxMessagesFromD1, + fetchRepliesForMessages, + listOutboxRepliesFromD1, + countOutboxRepliesFromD1, +} from "@/lib/inbox/d1-reads"; + +// ---- shared fixtures -------------------------------------------------------- + +const AGENT_ADDR = "bc1qxj5jtv8jwm7zv2nczn2xfq9agjgj0sqpsxn43h"; +const SENDER_ADDR = "bc1qp66jvxe765wgwpzqk8kcrmgh2mucyxg540mtzv"; +const REPLY_TARGET_ADDR = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; + +const TEST_AGENT = { + btcAddress: AGENT_ADDR, + stxAddress: "SP3EPDH1E2Y1M4W5GCK4YEJPQ9VW3APJB4Z1QEBNC", + displayName: "Frosty Narwhal", +}; + +const SENDER_AGENT = { + btcAddress: SENDER_ADDR, + stxAddress: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + displayName: "Solar Penguin", +}; + +const REPLY_TARGET_AGENT = { + btcAddress: REPLY_TARGET_ADDR, + stxAddress: "SP3GXCKM4AB5EB1KJ8V5QSTR1XMTW3R142VQS2NVW", + displayName: "Amber Otter", +}; + +const RECEIVED_MESSAGE = { + messageId: "msg_1778221238475_received", + fromAddress: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", // sender STX + toBtcAddress: AGENT_ADDR, + toStxAddress: "SP3EPDH1E2Y1M4W5GCK4YEJPQ9VW3APJB4Z1QEBNC", + content: "Hello agent!", + paymentSatoshis: 100, + sentAt: "2026-05-08T06:00:00.000Z", + authenticated: false, + paymentStatus: "confirmed" as const, +}; + +const SENT_REPLY = { + messageId: "msg_1778221238475_received", // parent ID + fromAddress: AGENT_ADDR, + toBtcAddress: REPLY_TARGET_ADDR, + reply: "Thanks for the message!", + signature: "sig_abc123base64", + repliedAt: "2026-05-08T07:00:00.000Z", +}; + +function buildGetRequest(address: string, query = ""): NextRequest { + return new NextRequest( + `https://aibtc.com/api/inbox/${address}${query}`, + { method: "GET" } + ); +} + +function buildContext(address: string) { + return { params: Promise.resolve({ address }) }; +} + +function setupDefaultMocks() { + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: { prepare: vi.fn() } as unknown as D1Database, + VERIFIED_AGENTS: {} as KVNamespace, + }, + ctx: { waitUntil: vi.fn() }, + }); + (lookupAgent as Mock).mockResolvedValue(TEST_AGENT); + (listInboxMessagesFromD1 as Mock).mockResolvedValue([RECEIVED_MESSAGE]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + (fetchRepliesForMessages as Mock).mockResolvedValue(new Map()); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); +}); + +// ---- sentCount restoration tests -------------------------------------------- + +describe("Phase 2.5 Step 3.3 — sentCount restoration in inbox-list GET", () => { + it("returns sentCount > 0 when countOutboxRepliesFromD1 returns positive count", async () => { + // Step 3.3 acceptance signal: inbox-list GET must now return real sentCount. + // Was stubbed to 0 in Step 3.1 (const sentCount = 0). + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(3); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + // sentCount must come from D1 countOutboxRepliesFromD1, not 0 stub + expect(body.inbox.sentCount).toBe(3); + expect(body.inbox.sentCount).toBeGreaterThan(0); + }); + + it("returns sentCount = 0 when agent has sent no replies", async () => { + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(0); + }); + + it("includes sentCount in economics.satsSent calculation", async () => { + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(2); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + // satsSent = sentCount * INBOX_PRICE_SATS (100 sats each) + expect(body.inbox.economics.satsSent).toBe(2 * 100); + }); + + it("sentCount is included in the empty-inbox self-documenting response", async () => { + // When totalCount === 0, the route returns early with a self-doc body. + // sentCount must still be 0 (not undefined) in that case. + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(0); + }); +}); + +// ---- partners-with-sent tests ----------------------------------------------- + +describe("Phase 2.5 Step 3.3 — partners-with-sent in inbox-list GET", () => { + beforeEach(() => { + // Set up additional agent lookups for partner resolution. + // Must handle both BTC and STX address lookups since the route resolves + // received-message partners via their STX address (fromAddress) and + // sent-reply partners via their BTC address (toBtcAddress). + (lookupAgent as Mock).mockImplementation((kv: unknown, addr: string) => { + if (addr === AGENT_ADDR) return Promise.resolve(TEST_AGENT); + if (addr === SENDER_AGENT.stxAddress) return Promise.resolve(SENDER_AGENT); + if (addr === SENDER_ADDR) return Promise.resolve(SENDER_AGENT); + if (addr === REPLY_TARGET_AGENT.stxAddress) return Promise.resolve(REPLY_TARGET_AGENT); + if (addr === REPLY_TARGET_ADDR) return Promise.resolve(REPLY_TARGET_AGENT); + return Promise.resolve(null); + }); + }); + + it("partners includes both received senders AND sent reply targets when both exist", async () => { + // Mock received messages (partner = sender) + (listInboxMessagesFromD1 as Mock).mockResolvedValue([RECEIVED_MESSAGE]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + // Mock sent replies (partner = toBtcAddress) + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([SENT_REPLY]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.partners).toBeDefined(); + + // Should have at least 2 partners: the sender (received) and the reply target (sent) + const partnerAddresses = body.inbox.partners.map((p: { btcAddress: string }) => p.btcAddress); + // The sender is resolved via SENDER_AGENT + expect(partnerAddresses).toContain(SENDER_ADDR); + // The reply target is in toBtcAddress of the sent reply + expect(partnerAddresses).toContain(REPLY_TARGET_ADDR); + }); + + it("partners from sent-only direction have direction='sent'", async () => { + // Only sent replies, no received messages + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([SENT_REPLY]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.partners).toBeDefined(); + + const replyTargetPartner = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === REPLY_TARGET_ADDR + ); + // Partners from outbox-only should have direction='sent' + if (replyTargetPartner) { + expect(replyTargetPartner.direction).toBe("sent"); + } + }); + + it("partners from both received and sent have direction='both' after merge", async () => { + // Same agent appears as both a sender (received msg) and reply target (sent reply) + const DUAL_PARTNER_ADDR = REPLY_TARGET_ADDR; + const receivedMsgFromDualPartner = { + ...RECEIVED_MESSAGE, + fromAddress: REPLY_TARGET_AGENT.stxAddress, // received from REPLY_TARGET + }; + const sentReplyToDualPartner = { + ...SENT_REPLY, + toBtcAddress: DUAL_PARTNER_ADDR, // also sent reply to REPLY_TARGET + }; + + (listInboxMessagesFromD1 as Mock).mockResolvedValue([receivedMsgFromDualPartner]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([sentReplyToDualPartner]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.partners).toBeDefined(); + + const dualPartner = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === DUAL_PARTNER_ADDR + ); + // After dedup + merge, this partner should have direction='both' + if (dualPartner) { + expect(dualPartner.direction).toBe("both"); + } + }); + + it("partners only from received when no sent replies (received-only path still works)", async () => { + (listInboxMessagesFromD1 as Mock).mockResolvedValue([RECEIVED_MESSAGE]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); // no sent replies + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + // Partners should still work from received-only path + expect(Array.isArray(body.inbox.partners)).toBe(true); + // The sender should appear as a received partner + const senderPartner = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === SENDER_ADDR + ); + if (senderPartner) { + expect(senderPartner.direction).toBe("received"); + } + }); +}); + +// ---- D1-throws fallback with new parallel queries --------------------------- + +describe("Phase 2.5 Step 3.3 — D1-throws fallback still works with added parallel queries", () => { + it("returns 503 when countOutboxRepliesFromD1 throws", async () => { + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: outbox table unavailable") + ); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(503); + expect(res.status).not.toBe(500); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("returns 503 when listOutboxRepliesFromD1 throws (partners requested)", async () => { + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + (listOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: schema mismatch") + ); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + }); +}); diff --git a/app/api/inbox/[address]/route.ts b/app/api/inbox/[address]/route.ts index 2ce83edb..91c97863 100644 --- a/app/api/inbox/[address]/route.ts +++ b/app/api/inbox/[address]/route.ts @@ -37,6 +37,8 @@ import { listInboxMessagesFromD1, countInboxMessagesFromD1, fetchRepliesForMessages, + listOutboxRepliesFromD1, + countOutboxRepliesFromD1, type StatusFilter, } from "@/lib/inbox/d1-reads"; @@ -252,14 +254,18 @@ export async function GET( let unreadCount: number; let totalCount: number; let receivedCount: number; + let sentCount: number; + let sentMessages: import("@/lib/inbox/types").OutboxReply[]; try { // D1 query: received messages with status filter and pagination. - // All four queries run in parallel to minimise latency: + // All six queries run in parallel to minimise latency: // [0] paginated message list (the page the caller asked for) // [1] unreadCount — live SELECT COUNT(*) WHERE read_at IS NULL (closes aibtc-mcp-server#497) // [2] totalCount — total matching the status filter (drives hasMore / pagination) // [3] receivedCount — total inbound messages regardless of filter (for economics) - [receivedMessages, unreadCount, totalCount, receivedCount] = await Promise.all([ + // [4] sentCount — total outbox replies sent by this agent (Phase 2.5 Step 3.3) + // [5] sentMessages — outbox replies for partner graph (only when includePartners) + [receivedMessages, unreadCount, totalCount, receivedCount, sentCount, sentMessages] = await Promise.all([ includeReceived ? listInboxMessagesFromD1(db, agent.btcAddress, limit, offset, statusFilter) : Promise.resolve([] as import("@/lib/inbox/types").InboxMessage[]), @@ -275,6 +281,12 @@ export async function GET( includeReceived ? countInboxMessagesFromD1(db, agent.btcAddress, "all") : Promise.resolve(0), + // sentCount: total outbox replies sent — restores the dimension stubbed in Step 3.1 + countOutboxRepliesFromD1(db, agent.btcAddress), + // sentMessages: outbox replies for partner graph computation (only when includePartners) + includePartners + ? listOutboxRepliesFromD1(db, agent.btcAddress, 100, 0) + : Promise.resolve([] as import("@/lib/inbox/types").OutboxReply[]), ]); } catch (e) { return NextResponse.json( @@ -287,8 +299,6 @@ export async function GET( ); } - const sentCount = 0; // Step 3.3: outbox flip not yet done - // Build inline replies map for the returned page const visibleMessageIds = receivedMessages.map((m) => m.messageId); const repliesMap = await fetchRepliesForMessages(db, visibleMessageIds); @@ -330,10 +340,11 @@ export async function GET( }); // Compute partner summary if requested. - // For now partners are computed from the received messages only (Step 3.3 will add sent). + // Step 3.3 restores the full partner graph: both received (inbound senders) + // and sent (outbox reply targets) directions are merged into the partner map. let partners: import("@/lib/inbox/types").InboxPartner[] | undefined; - if (includePartners && totalCount > 0) { - // Group received messages by partner (sender) address + if (includePartners && (totalCount > 0 || sentMessages.length > 0)) { + // Group messages by partner address — covers both directions const partnerMap = new Map; }>(); + // Received messages: partner is the sender (fromAddress = STX address) for (const msg of receivedMessages) { - // For received messages, partner is the sender (fromAddress = STX address) const partnerStxAddress = msg.fromAddress; const partnerBtcAddress = agentLookupMap.get(partnerStxAddress)?.btcAddress; const partnerKey = partnerBtcAddress || partnerStxAddress; @@ -366,6 +377,31 @@ export async function GET( } } + // Sent messages (outbox replies): partner is the reply target (toBtcAddress) + // This is always a BTC address (resolved at write time by insertReplyToD1). + for (const reply of sentMessages) { + const partnerBtcAddress = reply.toBtcAddress; + if (!partnerBtcAddress) continue; + const partnerKey = partnerBtcAddress; + + const existing = partnerMap.get(partnerKey); + if (existing) { + existing.messageCount++; + existing.directions.add("sent"); + if (new Date(reply.repliedAt).getTime() > new Date(existing.lastInteractionAt).getTime()) { + existing.lastInteractionAt = reply.repliedAt; + } + } else { + partnerMap.set(partnerKey, { + btcAddress: partnerBtcAddress, + stxAddress: undefined, + messageCount: 1, + lastInteractionAt: reply.repliedAt, + directions: new Set(["sent"]), + }); + } + } + // Resolve partner addresses to agent records for display names const partnerEntries = Array.from(partnerMap.entries()); const resolvedPartners = await Promise.all( @@ -373,7 +409,7 @@ export async function GET( const lookupAddress = data.stxAddress || data.btcAddress; const partnerAgent = lookupAddress ? await lookupAgent(kv, lookupAddress) : null; - // Determine final direction — all received-only for now (Step 3.3 adds sent) + // Determine final direction from the merged set let finalDirection: "sent" | "received" | "both"; if (data.directions.has("sent") && data.directions.has("received")) { finalDirection = "both"; diff --git a/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts b/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts new file mode 100644 index 00000000..9dafe4af --- /dev/null +++ b/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts @@ -0,0 +1,243 @@ +/** + * Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 read flip. + * + * Covers: + * 1. 200 — replies exist, returned correctly from D1 + * 2. Empty outbox — self-documenting response (no error) + * 3. 404 — agent not found + * 4. Tenant-discriminator security gate: reply written by addr_A MUST NOT appear + * in GET /api/outbox/addr_B — SQL WHERE from_btc_address=? enforces this. + * The route returns empty (not a leaked reply) when address doesn't match. + * 5. 503 — D1 throws → structured fallback (not unhandled 500) + * 6. sentCount restoration: inbox-list GET returns sentCount > 0 when D1 reports replies + * 7. partners-with-sent: partner graph merges both received (inbound senders) and + * sent (reply targets) into the partner map + * + * See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +// ---- module mocks (must be declared before route imports) ------------------- + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +vi.mock("@/lib/agent-lookup", () => ({ + lookupAgent: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-reads", () => ({ + listOutboxRepliesFromD1: vi.fn(), +})); + +vi.mock("@/lib/inbox", () => ({ + validateOutboxReply: vi.fn(), + getMessage: vi.fn(), + getReply: vi.fn(), + storeReply: vi.fn(), + updateMessage: vi.fn(), + buildReplyMessage: vi.fn(() => "Inbox Reply | msg_test | reply"), + decrementUnreadCount: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-dual-write", () => ({ + insertReplyToD1: vi.fn().mockResolvedValue(undefined), + updateMessageStateD1: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/bitcoin-verify", () => ({ + verifyBitcoinSignature: vi.fn(), +})); + +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), + isLogsRPC: () => false, +})); + +vi.mock("@/lib/env", () => ({ + shouldFailClosed: vi.fn(() => false), +})); + +vi.mock("@/lib/validation/address", () => ({ + isStxAddress: vi.fn(() => false), +})); + +// ---- imports after mocks ---------------------------------------------------- + +import { GET } from "../route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { lookupAgent } from "@/lib/agent-lookup"; +import { listOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; + +// ---- shared fixtures -------------------------------------------------------- + +const ADDR_A = "bc1qxj5jtv8jwm7zv2nczn2xfq9agjgj0sqpsxn43h"; +const ADDR_B = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; + +const AGENT_A = { + btcAddress: ADDR_A, + stxAddress: "SP3JR7JXFT7ZM9JKSQPBQG1HPT0D365MA5TN0P12E", + displayName: "Frosty Narwhal", +}; + +const AGENT_B = { + btcAddress: ADDR_B, + stxAddress: "SP3GXCKM4AB5EB1KJ8V5QSTR1XMTW3R142VQS2NVW", + displayName: "Amber Otter", +}; + +const REPLY_FROM_A = { + messageId: "msg_1778221238475_parent", + fromAddress: ADDR_A, + toBtcAddress: ADDR_B, + reply: "Thanks for the message!", + signature: "sig_abc123base64", + repliedAt: "2026-05-08T07:00:00.000Z", +}; + +function makeMockDB() { + const stmt = { + bind: vi.fn().mockReturnThis(), + first: vi.fn(), + all: vi.fn(), + run: vi.fn(), + raw: vi.fn(), + }; + return { + prepare: vi.fn().mockReturnValue(stmt), + batch: vi.fn(), + dump: vi.fn(), + exec: vi.fn(), + } as unknown as D1Database; +} + +function buildGetRequest(address: string): NextRequest { + return new NextRequest( + `https://aibtc.com/api/outbox/${address}`, + { method: "GET" } + ); +} + +function buildContext(address: string) { + return { params: Promise.resolve({ address }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: makeMockDB(), + VERIFIED_AGENTS: {} as KVNamespace, + }, + ctx: { waitUntil: vi.fn() }, + }); +}); + +// ---- tests ------------------------------------------------------------------ + +describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { + it("returns 200 with outbox shape when replies exist in D1", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox).toBeDefined(); + expect(body.outbox.replies).toHaveLength(1); + expect(body.outbox.replies[0]).toMatchObject({ + fromAddress: ADDR_A, + toBtcAddress: ADDR_B, + reply: "Thanks for the message!", + }); + expect(body.outbox.totalCount).toBe(1); + expect(body.agent.btcAddress).toBe(ADDR_A); + }); + + it("returns self-documenting empty response when no replies found", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.replies).toHaveLength(0); + expect(body.outbox.totalCount).toBe(0); + // Self-documenting — includes howToReply + expect(body.howToReply).toBeDefined(); + expect(body.endpoint).toBe("/api/outbox/[address]"); + }); + + it("returns 404 when agent not found", async () => { + (lookupAgent as Mock).mockResolvedValue(null); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Agent not found"); + }); + + it("tenant-discriminator security gate: GET /api/outbox/addr_B does NOT return addr_A's reply", async () => { + // BLOCK-ON-MERGE per #728 Step 3.3 spec (analog of #725 address-match guard). + // The SQL WHERE from_btc_address = ? ensures addr_B's query never matches addr_A's rows. + // When addr_B has no sent replies, D1 returns empty — not a leaked reply from addr_A. + (lookupAgent as Mock).mockResolvedValue(AGENT_B); + // D1 returns empty because ADDR_B has not sent any replies + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + + const res = await GET(buildGetRequest(ADDR_B), buildContext(ADDR_B)); + + expect(res.status).toBe(200); + const body = await res.json(); + // MUST be empty — addr_A's reply must NOT appear here + expect(body.outbox.replies).toHaveLength(0); + expect(body.outbox.totalCount).toBe(0); + + // Verify the D1 query was called with ADDR_B (not ADDR_A) + expect(listOutboxRepliesFromD1).toHaveBeenCalledOnce(); + const [, calledAddress] = (listOutboxRepliesFromD1 as Mock).mock.calls[0]; + expect(calledAddress).toBe(ADDR_B); + }); + + it("returns 503 with structured body when D1 throws — not unhandled 500", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: connection reset") + ); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(503); + expect(res.status).not.toBe(500); + const body = await res.json(); + expect(body).toMatchObject({ + error: "transient_d1_unavailable", + retry_after: 5, + }); + expect(body.message).toMatch(/temporarily unavailable/i); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("includes pagination shape in response", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.pagination).toBeDefined(); + expect(body.outbox.pagination).toMatchObject({ + limit: 20, + offset: 0, + }); + }); +}); diff --git a/app/api/outbox/[address]/route.ts b/app/api/outbox/[address]/route.ts index fb91013b..7b252913 100644 --- a/app/api/outbox/[address]/route.ts +++ b/app/api/outbox/[address]/route.ts @@ -14,12 +14,12 @@ import { storeReply, updateMessage, buildReplyMessage, - listInboxMessages, decrementUnreadCount, } from "@/lib/inbox"; import { isStxAddress } from "@/lib/validation/address"; import { shouldFailClosed } from "@/lib/env"; import { insertReplyToD1, updateMessageStateD1 } from "@/lib/inbox/d1-dual-write"; +import { listOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; /** Retry-After value (seconds) to return on 429s — matches the 60s binding window. */ const RATE_LIMIT_RETRY_AFTER = 60; @@ -513,20 +513,52 @@ export async function GET( ); } - // Fetch all messages with replies inline (single call, no N+1) - const { replies: replyMap } = await listInboxMessages( - kv, - agent.btcAddress, - 100, - 0, - { includeReplies: true } - ); + // ── Phase 2.5 Step 3.3: D1 read flip ────────────────────────────────────── + // The GET /api/outbox/[address] path now reads from D1 instead of KV. + // KV writes (POST handler) are NOT removed in this PR — that is Step 4. + // Security gate: listOutboxRepliesFromD1 filters by from_btc_address = ? + // so replies belonging to a different agent are never returned. + // + // See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) + // See: https://github.com/aibtcdev/landing-page/issues/697 (Phase 2.5 umbrella) + + // Parse query params for pagination + const url = new URL(request.url); + const limitParam = url.searchParams.get("limit"); + const offsetParam = url.searchParams.get("offset"); + const limit = limitParam + ? Math.min(Math.max(parseInt(limitParam, 10), 1), 100) + : 20; + const offset = offsetParam ? Math.max(parseInt(offsetParam, 10), 0) : 0; + + const db = env.DB as D1Database | undefined; + + if (!db) { + return NextResponse.json( + { error: "Database unavailable. Please try again shortly." }, + { status: 503 } + ); + } - // Collect all replies - const validReplies = Array.from(replyMap.values()); + // D1-throws fallback policy (per #728 / #722 dev-council Cycle 26 advisory): + // If D1 throws — transient unavailability, network error, schema mismatch — + // return 503 with a structured retry hint rather than an unstructured 500. + let replies: import("@/lib/inbox/types").OutboxReply[]; + try { + replies = await listOutboxRepliesFromD1(db, agent.btcAddress, limit, offset); + } catch (e) { + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Outbox database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } // If no replies, return self-documenting response - if (validReplies.length === 0) { + if (replies.length === 0) { return NextResponse.json({ endpoint: "/api/outbox/[address]", description: "Replies sent by this agent to incoming inbox messages.", @@ -537,6 +569,12 @@ export async function GET( outbox: { replies: [], totalCount: 0, + pagination: { + limit, + offset, + hasMore: false, + nextOffset: null, + }, }, howToReply: { endpoint: `POST /api/outbox/${agent.btcAddress}`, @@ -561,8 +599,14 @@ export async function GET( displayName: agent.displayName, }, outbox: { - replies: validReplies, - totalCount: validReplies.length, + replies, + totalCount: replies.length, + pagination: { + limit, + offset, + hasMore: replies.length === limit, + nextOffset: replies.length === limit ? offset + limit : null, + }, }, }); } diff --git a/lib/inbox/__tests__/d1-reads.test.ts b/lib/inbox/__tests__/d1-reads.test.ts index 86575db3..6d707ec7 100644 --- a/lib/inbox/__tests__/d1-reads.test.ts +++ b/lib/inbox/__tests__/d1-reads.test.ts @@ -25,6 +25,8 @@ import { listInboxMessagesFromD1, countInboxMessagesFromD1, fetchRepliesForMessages, + listOutboxRepliesFromD1, + countOutboxRepliesFromD1, type StatusFilter, } from "../d1-reads"; @@ -462,6 +464,156 @@ describe("fetchRepliesForMessages", () => { }); }); +// ── listOutboxRepliesFromD1 ─────────────────────────────────────────────────── + +describe("listOutboxRepliesFromD1 (Phase 2.5 Step 3.3)", () => { + const REPLIER_BTC = "bc1qp66jvxe765wgwpzqk8kcrmgh2mucyxg540mtzv"; + const SENT_REPLY_ROW = { + message_id: "reply_msg_1771381602504_test", + reply_to_message_id: "msg_1771381602504_test", + from_btc_address: REPLIER_BTC, + to_btc_address: BTC_ADDRESS, + content: "Great work — bookmarked.", + bitcoin_signature: + "Jx52I99dmnoFqmKkJXsLP4ELktANgZ6v1m1CFA7c5kz+Xr9W45m29QnabzGim5ubEzJP1eoynU/GjuRWMjRD9nQ=", + sent_at: "2026-02-19T22:14:43.426Z", + }; + + it("queries WHERE is_reply=1 AND from_btc_address=? with ORDER BY sent_at DESC", async () => { + const db = createMockD1(); + const stmtMock = createPreparedStatement([]); + (db.prepare as ReturnType).mockReturnValue(stmtMock); + + await listOutboxRepliesFromD1(db, REPLIER_BTC, 20, 0); + + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("FROM inbox_messages"); + expect(sql).toContain("WHERE is_reply = 1 AND from_btc_address = ?"); + expect(sql).toContain("ORDER BY sent_at DESC"); + expect(sql).toContain("LIMIT ? OFFSET ?"); + }); + + it("binds from_btc_address, limit, offset in order", async () => { + const db = createMockD1(); + const stmtMock = createPreparedStatement([]); + (db.prepare as ReturnType).mockReturnValue(stmtMock); + + await listOutboxRepliesFromD1(db, REPLIER_BTC, 50, 10); + + const bindArgs: unknown[] = stmtMock.bind.mock.calls[0]; + expect(bindArgs[0]).toBe(REPLIER_BTC); + expect(bindArgs[1]).toBe(50); // limit + expect(bindArgs[2]).toBe(10); // offset + }); + + it("maps D1 reply row to OutboxReply shape", async () => { + const stmtMock = createPreparedStatement([SENT_REPLY_ROW]); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const results = await listOutboxRepliesFromD1(db, REPLIER_BTC, 20, 0); + + expect(results).toHaveLength(1); + const reply = results[0]; + // replyRowToOutboxReply: messageId = reply_to_message_id (parent ID) + expect(reply.messageId).toBe(SENT_REPLY_ROW.reply_to_message_id); + expect(reply.fromAddress).toBe(SENT_REPLY_ROW.from_btc_address); + expect(reply.toBtcAddress).toBe(SENT_REPLY_ROW.to_btc_address); + expect(reply.reply).toBe(SENT_REPLY_ROW.content); + expect(reply.signature).toBe(SENT_REPLY_ROW.bitcoin_signature); + expect(reply.repliedAt).toBe(SENT_REPLY_ROW.sent_at); + }); + + it("returns empty array when no rows", async () => { + const db = createMockD1([]); + const result = await listOutboxRepliesFromD1(db, REPLIER_BTC, 20, 0); + expect(result).toHaveLength(0); + }); + + it("tenant-discriminator security gate: SQL WHERE from_btc_address=? enforces address isolation", async () => { + // This test documents the security property: a reply written by REPLIER_BTC + // will NOT be returned when the query uses a different address. + // The SQL gate (WHERE is_reply = 1 AND from_btc_address = ?) enforces this at the DB level. + const db = createMockD1(); + const stmtMock = createPreparedStatement([]); // D1 returns empty for non-matching address + (db.prepare as ReturnType).mockReturnValue(stmtMock); + + // Query with ADDR_B (not the replier) + const ADDR_B = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; + const results = await listOutboxRepliesFromD1(db, ADDR_B, 100, 0); + + // Must be empty — the SQL WHERE clause prevents cross-agent leakage + expect(results).toHaveLength(0); + + // Verify the query was called with ADDR_B — the guard happens in SQL + const bindArgs: unknown[] = stmtMock.bind.mock.calls[0]; + expect(bindArgs[0]).toBe(ADDR_B); + }); +}); + +// ── countOutboxRepliesFromD1 ────────────────────────────────────────────────── + +describe("countOutboxRepliesFromD1 (Phase 2.5 Step 3.3 — sentCount restoration)", () => { + const REPLIER_BTC = "bc1qp66jvxe765wgwpzqk8kcrmgh2mucyxg540mtzv"; + + it("queries SELECT COUNT(*) WHERE is_reply=1 AND from_btc_address=?", async () => { + const stmtMock = createPreparedStatement([], { cnt: 3 }); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const count = await countOutboxRepliesFromD1(db, REPLIER_BTC); + + expect(count).toBe(3); + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("SELECT COUNT(*)"); + expect(sql).toContain("FROM inbox_messages"); + expect(sql).toContain("is_reply = 1"); + expect(sql).toContain("from_btc_address = ?"); + }); + + it("returns 0 when db.first() returns null", async () => { + const stmtMock = createPreparedStatement([], null); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const count = await countOutboxRepliesFromD1(db, REPLIER_BTC); + expect(count).toBe(0); + }); + + it("sentCount restoration: returns > 0 for known-replier address (Step 3.3 acceptance signal)", async () => { + // This test represents the acceptance signal for Step 3.3: + // POST-flip, countOutboxRepliesFromD1 must return > 0 for an address that has sent replies. + // Was stubbed to 0 in Step 3.1 ("const sentCount = 0;"). + const stmtMock = createPreparedStatement([], { cnt: 5 }); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const count = await countOutboxRepliesFromD1(db, REPLIER_BTC); + expect(count).toBeGreaterThan(0); // Step 3.3 acceptance signal: sentCount > 0 + }); + + it("binds from_btc_address as the first positional param", async () => { + const stmtMock = createPreparedStatement([], { cnt: 0 }); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + await countOutboxRepliesFromD1(db, REPLIER_BTC); + + const bindArgs: unknown[] = stmtMock.bind.mock.calls[0]; + expect(bindArgs[0]).toBe(REPLIER_BTC); + }); +}); + // ── Cache-key invariant: structural verification ────────────────────────────── describe("cache-key invariants (structural verification)", () => { @@ -476,6 +628,8 @@ describe("cache-key invariants (structural verification)", () => { expect(listInboxMessagesFromD1.length).toBe(5); // (db, btcAddress, limit, offset, status) expect(countInboxMessagesFromD1.length).toBe(3); // (db, btcAddress, status) expect(fetchRepliesForMessages.length).toBe(2); // (db, parentMessageIds) + expect(listOutboxRepliesFromD1.length).toBe(4); // (db, btcAddress, limit, offset) + expect(countOutboxRepliesFromD1.length).toBe(2); // (db, btcAddress) }); it("Invariant 3: read helpers are called with explicit inputs — no implicit cache-before-auth", async () => { diff --git a/lib/inbox/d1-reads.ts b/lib/inbox/d1-reads.ts index 3c32a549..5656342c 100644 --- a/lib/inbox/d1-reads.ts +++ b/lib/inbox/d1-reads.ts @@ -3,6 +3,8 @@ * * Phase 2.5 Step 3.1 — flip inbox-list GET from KV reads to D1 SELECTs. * Phase 2.5 Step 3.2 — adds getInboxMessageFromD1 for the single-message GET. + * Phase 2.5 Step 3.3 — adds listOutboxRepliesFromD1 + countOutboxRepliesFromD1 + * for the outbox GET flip and sentCount/partners restoration in inbox-list. * KV writes are NOT removed here (that is Step 4). * * These helpers query the inbox_messages table that is being populated by @@ -21,6 +23,7 @@ * * See: https://github.com/aibtcdev/landing-page/issues/721 (Step 3.1 spec) * See: https://github.com/aibtcdev/landing-page/issues/725 (Step 3.2 spec) + * See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) * See: https://github.com/aibtcdev/landing-page/issues/697 (Phase 2.5 umbrella) * See: https://github.com/aibtcdev/landing-page/issues/723 (cache-invariant extraction) */ @@ -265,5 +268,73 @@ export async function getInboxMessageFromD1( return rowToInboxMessage(row); } +/** + * Fetch a page of outbox replies sent by an agent from D1. + * + * Security gate: SQL WHERE clause filters by `from_btc_address = ?` so replies + * belonging to a different address are never returned, even if the caller + * supplies a mismatched URL address. + * + * SQL shape (refs #728 Step 3.3 spec): + * SELECT … FROM inbox_messages + * WHERE is_reply = 1 AND from_btc_address = ? + * ORDER BY sent_at DESC + * LIMIT ? OFFSET ? + * + * Used by GET /api/outbox/[address] and by the sentCount/partners restoration + * in GET /api/inbox/[address]. + */ +export async function listOutboxRepliesFromD1( + db: D1Database, + btcAddress: string, + limit: number, + offset: number +): Promise { + const sql = ` + SELECT + message_id, reply_to_message_id, from_btc_address, to_btc_address, + content, bitcoin_signature, sent_at + FROM inbox_messages + WHERE is_reply = 1 AND from_btc_address = ? + ORDER BY sent_at DESC + LIMIT ? OFFSET ? + `; + + const result = await db + .prepare(sql) + .bind(btcAddress, limit, offset) + .all(); + + return (result.results ?? []).map(replyRowToOutboxReply); +} + +/** + * Count outbox replies sent by an agent from D1. + * + * Used to restore the `sentCount` dimension in GET /api/inbox/[address] that + * was stubbed to 0 in Step 3.1. + * + * SQL shape: + * SELECT COUNT(*) FROM inbox_messages + * WHERE is_reply = 1 AND from_btc_address = ? + */ +export async function countOutboxRepliesFromD1( + db: D1Database, + btcAddress: string +): Promise { + const sql = ` + SELECT COUNT(*) AS cnt + FROM inbox_messages + WHERE is_reply = 1 AND from_btc_address = ? + `; + + const row = await db + .prepare(sql) + .bind(btcAddress) + .first<{ cnt: number }>(); + + return row?.cnt ?? 0; +} + // Re-export the prefix so tests can verify synthesized IDs export { REPLY_D1_PK_PREFIX }; From 3e5f68a1c133b06b19d5832d43e3150c1a0c4ba4 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Sun, 10 May 2026 20:37:14 -0700 Subject: [PATCH 2/2] fix(outbox): correct pagination metadata + NaN guard + sent-only-inbox path (review fixups for #732) Addresses review feedback from arc0btc, codex (P1 x2), and copilot (x5) on PR #732. Pagination metadata correctness - outbox.totalCount now comes from countOutboxRepliesFromD1 (lifetime total), not page-scoped replies.length. Pre-fix the canonical address reported totalCount=20 (page) instead of ~39 (true total). - hasMore = offset + replies.length < totalCount. Pre-fix the edge case replies.length === limit on the final page incorrectly reported hasMore=true. - Out-of-range offset (offset > 0, totalCount > 0, empty page) now returns the normal envelope with accurate pagination, not the self-doc shape. NaN guard for query params - Non-numeric ?limit / ?offset now return 400 invalid_query_param instead of letting NaN propagate to D1 and trigger 503. Bounds: limit 1-100, offset >= 0, both must be finite integers. Sent-only-inbox path (inbox-list) - Self-doc early-return now requires totalCount === 0 AND sentCount === 0. Pre-fix, an agent with sent replies but no received messages had sentCount hardcoded to 0 and partners hardcoded to [] in the response, making the Step 3.3 restoration unreachable for that scenario. D1 helper tightening - listOutboxRepliesFromD1: drop unused message_id from SELECT + D1ReplyRow. The mapper only references reply_to_message_id. Tests - 6 new outbox tests: totalCount from COUNT(*), hasMore boundary, out-of-range offset, NaN/0/101/-1/1.5 rejection (each 400), 100/0 acceptance. - 2 new inbox tests: sent-only sentCount in normal envelope, sent-only with partners exposes sent-direction entry. - Updated existing tests to mock countOutboxRepliesFromD1. - Trimmed outbox test-file header to scope (route assertions only). Partner dedup STX/BTC edge case (arc0btc question): filed as follow-up #733; out of scope here per scope discipline. Refs: #728 (Step 3.3 spec), #732 (this PR) Follow-up: #733 (partner dedup edge case) --- .../__tests__/d1-sentcount-partners.test.ts | 54 ++++- app/api/inbox/[address]/route.ts | 7 +- .../[address]/__tests__/d1-reads-flip.test.ts | 202 +++++++++++++++++- app/api/outbox/[address]/route.ts | 56 +++-- lib/inbox/d1-reads.ts | 5 +- 5 files changed, 295 insertions(+), 29 deletions(-) diff --git a/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts b/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts index 96647093..fe725434 100644 --- a/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts +++ b/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts @@ -205,8 +205,8 @@ describe("Phase 2.5 Step 3.3 — sentCount restoration in inbox-list GET", () => }); it("sentCount is included in the empty-inbox self-documenting response", async () => { - // When totalCount === 0, the route returns early with a self-doc body. - // sentCount must still be 0 (not undefined) in that case. + // When both totalCount === 0 AND sentCount === 0, the route returns the + // self-doc body. sentCount must still be 0 (not undefined) in that case. (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); @@ -217,6 +217,56 @@ describe("Phase 2.5 Step 3.3 — sentCount restoration in inbox-list GET", () => const body = await res.json(); expect(body.inbox.sentCount).toBe(0); }); + + it("sent-only inbox (totalCount===0 but sentCount>0) returns sentCount in normal envelope, not self-doc", async () => { + // Regression fix: agent has sent replies but has never received a message. + // Before the fix, the totalCount===0 early-return hardcoded sentCount:0 + // and partners:[], so the sent-only path was unreachable. After the fix, + // the self-doc path requires both totalCount===0 AND sentCount===0. + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(3); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(3); + expect(body.inbox.totalCount).toBe(0); + expect(body.inbox.economics.satsSent).toBe(3 * 100); + // Must NOT be the self-doc shape + expect(body.endpoint).toBeUndefined(); + expect(body.howToSend).toBeDefined(); // howToSend is in both shapes; check via other markers + }); + + it("sent-only inbox with partners requested exposes sent-direction partners", async () => { + // Regression check for the partners-with-sent path in the sent-only case. + (lookupAgent as Mock).mockImplementation((kv: unknown, addr: string) => { + if (addr === AGENT_ADDR) return Promise.resolve(TEST_AGENT); + if (addr === REPLY_TARGET_ADDR) return Promise.resolve(REPLY_TARGET_AGENT); + return Promise.resolve(null); + }); + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([SENT_REPLY]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(1); + expect(Array.isArray(body.inbox.partners)).toBe(true); + expect(body.inbox.partners.length).toBeGreaterThan(0); + const target = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === REPLY_TARGET_ADDR + ); + expect(target).toBeDefined(); + expect(target.direction).toBe("sent"); + }); }); // ---- partners-with-sent tests ----------------------------------------------- diff --git a/app/api/inbox/[address]/route.ts b/app/api/inbox/[address]/route.ts index 91c97863..1359df0e 100644 --- a/app/api/inbox/[address]/route.ts +++ b/app/api/inbox/[address]/route.ts @@ -464,8 +464,11 @@ export async function GET( partners = dedupedPartners.slice(0, 10); } - // If no messages, return self-documenting response - if (totalCount === 0) { + // If the agent has truly never had any inbox activity (no received messages + // AND no sent replies), return the self-documenting response. An agent that + // has only sent replies (sentCount > 0, totalCount === 0) falls through to + // the normal envelope so sentCount/economics/partners are exposed. + if (totalCount === 0 && sentCount === 0) { return NextResponse.json({ endpoint: "/api/inbox/[address]", description: diff --git a/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts b/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts index 9dafe4af..1db02bb4 100644 --- a/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts +++ b/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts @@ -1,17 +1,22 @@ /** * Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 read flip. * + * Scope: outbox-route assertions only. The inbox-list sentCount restoration + * and partners-with-sent coverage live in + * `app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts`. + * * Covers: * 1. 200 — replies exist, returned correctly from D1 - * 2. Empty outbox — self-documenting response (no error) + * 2. Empty outbox — self-documenting response only when totalCount === 0 * 3. 404 — agent not found * 4. Tenant-discriminator security gate: reply written by addr_A MUST NOT appear * in GET /api/outbox/addr_B — SQL WHERE from_btc_address=? enforces this. * The route returns empty (not a leaked reply) when address doesn't match. * 5. 503 — D1 throws → structured fallback (not unhandled 500) - * 6. sentCount restoration: inbox-list GET returns sentCount > 0 when D1 reports replies - * 7. partners-with-sent: partner graph merges both received (inbound senders) and - * sent (reply targets) into the partner map + * 6. Pagination metadata: totalCount comes from COUNT(*), not page length; + * hasMore/nextOffset derived from offset + replies.length < totalCount + * 7. Out-of-range offset returns normal envelope with empty replies, not self-doc + * 8. NaN guard: non-numeric ?limit / ?offset returns 400, not 503 * * See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) */ @@ -31,6 +36,7 @@ vi.mock("@/lib/agent-lookup", () => ({ vi.mock("@/lib/inbox/d1-reads", () => ({ listOutboxRepliesFromD1: vi.fn(), + countOutboxRepliesFromD1: vi.fn(), })); vi.mock("@/lib/inbox", () => ({ @@ -71,7 +77,7 @@ vi.mock("@/lib/validation/address", () => ({ import { GET } from "../route"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { lookupAgent } from "@/lib/agent-lookup"; -import { listOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; +import { listOutboxRepliesFromD1, countOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; // ---- shared fixtures -------------------------------------------------------- @@ -140,10 +146,18 @@ beforeEach(() => { // ---- tests ------------------------------------------------------------------ +function buildGetRequestWithQuery(address: string, query: string): NextRequest { + return new NextRequest( + `https://aibtc.com/api/outbox/${address}${query}`, + { method: "GET" } + ); +} + describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { it("returns 200 with outbox shape when replies exist in D1", async () => { (lookupAgent as Mock).mockResolvedValue(AGENT_A); (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); @@ -160,9 +174,10 @@ describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { expect(body.agent.btcAddress).toBe(ADDR_A); }); - it("returns self-documenting empty response when no replies found", async () => { + it("returns self-documenting empty response when totalCount === 0", async () => { (lookupAgent as Mock).mockResolvedValue(AGENT_A); (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); @@ -192,6 +207,7 @@ describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { (lookupAgent as Mock).mockResolvedValue(AGENT_B); // D1 returns empty because ADDR_B has not sent any replies (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); const res = await GET(buildGetRequest(ADDR_B), buildContext(ADDR_B)); @@ -201,17 +217,21 @@ describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { expect(body.outbox.replies).toHaveLength(0); expect(body.outbox.totalCount).toBe(0); - // Verify the D1 query was called with ADDR_B (not ADDR_A) + // Verify both D1 queries were called with ADDR_B (not ADDR_A) expect(listOutboxRepliesFromD1).toHaveBeenCalledOnce(); - const [, calledAddress] = (listOutboxRepliesFromD1 as Mock).mock.calls[0]; - expect(calledAddress).toBe(ADDR_B); + const [, listCalledAddress] = (listOutboxRepliesFromD1 as Mock).mock.calls[0]; + expect(listCalledAddress).toBe(ADDR_B); + expect(countOutboxRepliesFromD1).toHaveBeenCalledOnce(); + const [, countCalledAddress] = (countOutboxRepliesFromD1 as Mock).mock.calls[0]; + expect(countCalledAddress).toBe(ADDR_B); }); - it("returns 503 with structured body when D1 throws — not unhandled 500", async () => { + it("returns 503 with structured body when listOutboxRepliesFromD1 throws — not unhandled 500", async () => { (lookupAgent as Mock).mockResolvedValue(AGENT_A); (listOutboxRepliesFromD1 as Mock).mockRejectedValue( new Error("D1_ERROR: connection reset") ); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); @@ -226,9 +246,24 @@ describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { expect(res.headers.get("Retry-After")).toBe("5"); }); + it("returns 503 with structured body when countOutboxRepliesFromD1 throws", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: count query failed") + ); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + }); + it("includes pagination shape in response", async () => { (lookupAgent as Mock).mockResolvedValue(AGENT_A); (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); @@ -241,3 +276,150 @@ describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { }); }); }); + +describe("Phase 2.5 Step 3.3 — pagination metadata correctness", () => { + it("totalCount reflects COUNT(*) result, not the current page length", async () => { + // Agent has 50 lifetime replies but the page returns only 20 (default limit). + // totalCount must be 50, not 20. + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + const pageReplies = Array.from({ length: 20 }, (_, i) => ({ + ...REPLY_FROM_A, + messageId: `msg_${i}_parent`, + })); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue(pageReplies); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(50); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.totalCount).toBe(50); + expect(body.outbox.replies).toHaveLength(20); + expect(body.outbox.pagination.hasMore).toBe(true); + expect(body.outbox.pagination.nextOffset).toBe(20); + }); + + it("hasMore is false on the final full page (replies.length === limit but no remaining rows)", async () => { + // Edge case: page exactly fills (limit=20) but there are no more rows. + // Pre-fix this reported hasMore: true incorrectly. + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + const pageReplies = Array.from({ length: 20 }, (_, i) => ({ + ...REPLY_FROM_A, + messageId: `msg_${i}_parent`, + })); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue(pageReplies); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(20); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.pagination.hasMore).toBe(false); + expect(body.outbox.pagination.nextOffset).toBeNull(); + }); + + it("out-of-range offset returns normal envelope with empty replies, not self-doc", async () => { + // Agent has 5 replies; caller requests offset=100. D1 returns empty page, + // but totalCount=5 means the agent does have history. Response should be + // the normal envelope with accurate pagination, NOT the self-doc. + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(5); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?offset=100"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.replies).toHaveLength(0); + expect(body.outbox.totalCount).toBe(5); + expect(body.outbox.pagination.offset).toBe(100); + expect(body.outbox.pagination.hasMore).toBe(false); + // Must NOT be the self-doc shape + expect(body.howToReply).toBeUndefined(); + expect(body.endpoint).toBeUndefined(); + }); +}); + +describe("Phase 2.5 Step 3.3 — query param validation (NaN guard)", () => { + it("rejects non-numeric ?limit with 400, not 503", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=abc"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + expect(res.status).not.toBe(503); + const body = await res.json(); + expect(body.error).toBe("invalid_query_param"); + // D1 helpers must never be invoked for invalid input + expect(listOutboxRepliesFromD1).not.toHaveBeenCalled(); + expect(countOutboxRepliesFromD1).not.toHaveBeenCalled(); + }); + + it("rejects out-of-range ?limit=0 with 400", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=0"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_query_param"); + }); + + it("rejects ?limit=101 with 400 (max 100)", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=101"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + }); + + it("rejects negative ?offset with 400", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?offset=-1"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + }); + + it("rejects non-integer ?limit=1.5 with 400", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=1.5"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + }); + + it("accepts ?limit=100 and ?offset=0 (boundary)", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=100&offset=0"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.pagination.limit).toBe(100); + expect(body.outbox.pagination.offset).toBe(0); + }); +}); diff --git a/app/api/outbox/[address]/route.ts b/app/api/outbox/[address]/route.ts index 7b252913..cb72f880 100644 --- a/app/api/outbox/[address]/route.ts +++ b/app/api/outbox/[address]/route.ts @@ -19,7 +19,7 @@ import { import { isStxAddress } from "@/lib/validation/address"; import { shouldFailClosed } from "@/lib/env"; import { insertReplyToD1, updateMessageStateD1 } from "@/lib/inbox/d1-dual-write"; -import { listOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; +import { listOutboxRepliesFromD1, countOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; /** Retry-After value (seconds) to return on 429s — matches the 60s binding window. */ const RATE_LIMIT_RETRY_AFTER = 60; @@ -522,14 +522,36 @@ export async function GET( // See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) // See: https://github.com/aibtcdev/landing-page/issues/697 (Phase 2.5 umbrella) - // Parse query params for pagination + // Parse query params for pagination. + // Validate before binding to D1: non-numeric inputs (e.g. ?limit=abc) must + // produce 400, not 503 from a downstream D1 NaN bind throw. const url = new URL(request.url); const limitParam = url.searchParams.get("limit"); const offsetParam = url.searchParams.get("offset"); - const limit = limitParam - ? Math.min(Math.max(parseInt(limitParam, 10), 1), 100) - : 20; - const offset = offsetParam ? Math.max(parseInt(offsetParam, 10), 0) : 0; + + let limit = 20; + if (limitParam !== null) { + const parsed = Number(limitParam); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + return NextResponse.json( + { error: "invalid_query_param", message: "limit must be an integer between 1 and 100" }, + { status: 400 } + ); + } + limit = parsed; + } + + let offset = 0; + if (offsetParam !== null) { + const parsed = Number(offsetParam); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + return NextResponse.json( + { error: "invalid_query_param", message: "offset must be a non-negative integer" }, + { status: 400 } + ); + } + offset = parsed; + } const db = env.DB as D1Database | undefined; @@ -543,9 +565,15 @@ export async function GET( // D1-throws fallback policy (per #728 / #722 dev-council Cycle 26 advisory): // If D1 throws — transient unavailability, network error, schema mismatch — // return 503 with a structured retry hint rather than an unstructured 500. + // totalCount is queried in parallel with the page list so pagination metadata + // reflects the full matching row count, not just the current page length. let replies: import("@/lib/inbox/types").OutboxReply[]; + let totalCount: number; try { - replies = await listOutboxRepliesFromD1(db, agent.btcAddress, limit, offset); + [replies, totalCount] = await Promise.all([ + listOutboxRepliesFromD1(db, agent.btcAddress, limit, offset), + countOutboxRepliesFromD1(db, agent.btcAddress), + ]); } catch (e) { return NextResponse.json( { @@ -557,8 +585,11 @@ export async function GET( ); } - // If no replies, return self-documenting response - if (replies.length === 0) { + // Self-documenting response only when the agent has truly never sent a + // reply (totalCount === 0). Out-of-range pages (offset > 0 but the agent + // does have history) get the normal envelope with empty `replies` and + // accurate pagination so clients can recover. + if (totalCount === 0) { return NextResponse.json({ endpoint: "/api/outbox/[address]", description: "Replies sent by this agent to incoming inbox messages.", @@ -593,6 +624,7 @@ export async function GET( }); } + const hasMore = offset + replies.length < totalCount; return NextResponse.json({ agent: { btcAddress: agent.btcAddress, @@ -600,12 +632,12 @@ export async function GET( }, outbox: { replies, - totalCount: replies.length, + totalCount, pagination: { limit, offset, - hasMore: replies.length === limit, - nextOffset: replies.length === limit ? offset + limit : null, + hasMore, + nextOffset: hasMore ? offset + replies.length : null, }, }, }); diff --git a/lib/inbox/d1-reads.ts b/lib/inbox/d1-reads.ts index 5656342c..08f84b61 100644 --- a/lib/inbox/d1-reads.ts +++ b/lib/inbox/d1-reads.ts @@ -62,7 +62,6 @@ interface D1InboxRow { * A single reply row (is_reply=1) joined or fetched for inline enrichment. */ interface D1ReplyRow { - message_id: string; reply_to_message_id: string; from_btc_address: string | null; to_btc_address: string; @@ -207,7 +206,7 @@ export async function fetchRepliesForMessages( const placeholders = parentMessageIds.map(() => "?").join(", "); const sql = ` SELECT - message_id, reply_to_message_id, from_btc_address, to_btc_address, + reply_to_message_id, from_btc_address, to_btc_address, content, bitcoin_signature, sent_at FROM inbox_messages WHERE is_reply = 1 @@ -292,7 +291,7 @@ export async function listOutboxRepliesFromD1( ): Promise { const sql = ` SELECT - message_id, reply_to_message_id, from_btc_address, to_btc_address, + reply_to_message_id, from_btc_address, to_btc_address, content, bitcoin_signature, sent_at FROM inbox_messages WHERE is_reply = 1 AND from_btc_address = ?