From b2bad52cb4dae05213526d57a8a83f02c3cf5ccc Mon Sep 17 00:00:00 2001 From: Vichy724 <312135133+Vichy724@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:05:04 +0200 Subject: [PATCH 1/2] fix(nft): report externally signed trades to the Baked Bazaar indexer Every NFT tool calls `logTransaction` after its own send and confirm. With an external signer the flow stops at the signing step, so those calls never ran and a listing or offer placed through a hosted deployment stayed invisible until the indexer's own scan found it. The report now travels with the transaction: the NFT tools pass a `bazaarLog` ({ type, nftMint, price? }) through `SignContext`, `ExternalSigner` echoes it in `needs_signature`, and `submit_signed_tx` takes it back and reports it once the transaction confirms. `sendNftTx` makes the same report for a local signer, so the six call sites no longer repeat it. --- CHANGELOG.md | 3 ++ README.md | 3 ++ src/core/liquidity/send.ts | 7 +++-- src/core/nft/bazaar.ts | 10 +++++- src/core/nft/index.ts | 45 +++++++++++++++++---------- src/core/signer.test.ts | 13 ++++++++ src/core/signer.ts | 13 +++++++- src/core/submit.test.ts | 63 +++++++++++++++++++++++++++++++++++++- src/core/submit.ts | 7 +++++ src/mcp/createServer.ts | 20 +++++++++++- 10 files changed, 162 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 855e9b3..2cd080b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The library failed to bundle for edge runtimes and browsers (Cloudflare Workers, Vercel Edge): Anchor was default-imported, and its browser build — picked under the `workerd` / `browser` conditions — has no default export and no `Wallet`. It is now imported by name. +- NFT trades signed externally were never reported to Baked Bazaar's indexer: the report ran after + the send, and in external mode the flow stops before it. The NFT tools now put a `bazaarLog` in + `needs_signature`, and `submit_signed_tx` accepts it back and reports the trade once it confirms. ### Security diff --git a/README.md b/README.md index 432b5be..fdf3174 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,9 @@ COOKIE_MCP_ALLOWED_HOSTS=mcp.example.com COOKIE_MCP_CORS_ORIGIN=https://app.exam the exact text; call `deploy_token` again with `loginSignature: { message, signature }`. In external mode the session is not cached server-side (the wallet header proves nothing), so every launch asks for its own login signature. +- The NFT tools add `bazaarLog`. Pass it back to `submit_signed_tx` and, once the transaction + confirms, it tells Baked Bazaar's indexer about the trade (a local signer does this itself), so the + listing or offer shows up without waiting for the indexer's own scan. - Blockhashes expire in about a minute. If the wallet prompt is slow, `submit_signed_tx` reports the timeout with the signature and a "do not retry blindly" hint; re-run the tool for fresh bytes. - The HTTP server is stateless (one fresh server per POST) and answers `/healthz`. A loopback bind diff --git a/src/core/liquidity/send.ts b/src/core/liquidity/send.ts index 75ac060..a5c490e 100644 --- a/src/core/liquidity/send.ts +++ b/src/core/liquidity/send.ts @@ -5,13 +5,14 @@ import { Keypair, Transaction, type Connection, type Signer } from "@solana/web3 import { confirmSent } from "../confirm"; import { CookieMcpError } from "../errors"; -import { signWithCosigners, type TxSigner } from "../signer"; +import { signWithCosigners, type SignContext, type TxSigner } from "../signer"; /** * Simulate, sign, send, and confirm a legacy Transaction. `signer` is the wallet and fee payer; * `cosigners` are ephemeral keypairs (position mints, transfer authorities) that sign first. `what` * names the action for the "sent but unconfirmed" warning — pass it so a retry-unsafe timeout is - * unambiguous. With an external signer this stops after the simulation with `SignatureRequired`. + * unambiguous. With an external signer this stops after the simulation with `SignatureRequired`; + * `extra` is echoed into it (the marketplace `bazaarLog`). */ export async function signSendConfirm( conn: Connection, @@ -19,6 +20,7 @@ export async function signSendConfirm( signer: TxSigner, cosigners: Signer[], what = "transaction", + extra?: Pick, ): Promise { const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash("confirmed"); tx.recentBlockhash = blockhash; @@ -43,6 +45,7 @@ export async function signSendConfirm( blockhash, lastValidBlockHeight, submit: { via: "cookie-rpc" }, + ...extra, }); const signature = await conn.sendRawTransaction(tx.serialize()); return confirmSent(conn, { signature, blockhash, lastValidBlockHeight }, what); diff --git a/src/core/nft/bazaar.ts b/src/core/nft/bazaar.ts index 4d7b7c6..b003f49 100644 --- a/src/core/nft/bazaar.ts +++ b/src/core/nft/bazaar.ts @@ -116,9 +116,17 @@ export async function fetchCollectionStats(symbol: string): Promise): Promise { +export async function logTransaction(payload: { signature: string } & BazaarLog): Promise { try { await fetchJson(`${base()}/log-transaction`, { method: "POST", diff --git a/src/core/nft/index.ts b/src/core/nft/index.ts index fe6e465..6dd86bc 100644 --- a/src/core/nft/index.ts +++ b/src/core/nft/index.ts @@ -45,6 +45,7 @@ import { fetchCollectionStats, logTransaction, type BazaarListing, + type BazaarLog, type BazaarOffer, } from "./bazaar"; @@ -386,9 +387,7 @@ export async function listNft(args: { const sellerTokenAccount = nftTokenAccount(mint, seller); const sell = buildSellIx({ seller, sellerTokenAccount, nftMint: mint, price: priceLamports }); - const signature = await sendNftTx(conn, [sell], signer); - await logTransaction({ - signature, + const signature = await sendNftTx(conn, [sell], signer, { type: "list", nftMint: args.mint, price: priceLamports.toString(), @@ -424,8 +423,10 @@ export async function cancelListing(args: { mint: string }): Promise side: "publicBuy", }); const withdraw = buildWithdrawIx({ wallet: buyer, amount: price }); - const signature = await sendNftTx(conn, [cancel, withdraw], signer); - await logTransaction({ signature, type: "cancel-offer", nftMint: args.mint }); + const signature = await sendNftTx(conn, [cancel, withdraw], signer, { + type: "cancel-offer", + nftMint: args.mint, + }); return { signature, action: "cancel_offer", @@ -615,9 +624,7 @@ export async function acceptOffer(args: { mint: string; buyer?: string }): Promi buyerSide: "publicBuy", }), ]; - const signature = await sendNftTx(conn, ixs, signer); - await logTransaction({ - signature, + const signature = await sendNftTx(conn, ixs, signer, { type: "accept-offer", nftMint: args.mint, price: price.toString(), @@ -658,12 +665,18 @@ async function requireActiveListing(mint: string): Promise { return listing; } +/** + * Simulate, sign, send, confirm, then tell the indexer. With an external signer the flow stops at the + * signing step, so `log` travels in `needs_signature` and `submit_signed_tx` reports it instead. + */ async function sendNftTx( conn: Connection, ixs: TransactionInstruction[], signer: TxSigner, - what = "NFT", + log: BazaarLog, ): Promise { const tx = new Transaction().add(...ixs); - return signSendConfirm(conn, tx, signer, [], what); + const signature = await signSendConfirm(conn, tx, signer, [], "NFT", { bazaarLog: log }); + await logTransaction({ signature, ...log }); + return signature; } diff --git a/src/core/signer.test.ts b/src/core/signer.test.ts index 8de735e..e9f4b1d 100644 --- a/src/core/signer.test.ts +++ b/src/core/signer.test.ts @@ -138,6 +138,19 @@ describe("ExternalSigner", () => { expect(() => assertFullySigned(back)).not.toThrow(); }); + it("echoes a marketplace bazaarLog and asks for it back", async () => { + const bazaarLog = { + type: "offer" as const, + nftMint: cosigner.publicKey.toBase58(), + price: "5", + }; + const err = await signer.signTransaction(legacyTx(), { ...ctx, bazaarLog }).catch((e) => e); + const p = (err as SignatureRequired).payload; + if (p.kind !== "transaction") throw new Error("expected a transaction payload"); + expect(p.bazaarLog).toEqual(bazaarLog); + expect(p.next).toMatch(/lastValidBlockHeight\/bazaarLog fields/); + }); + it("stops a v0 flow the same way", async () => { const tx = v0Tx(); const err = await signWithCosigners(signer, tx, [cosigner], { diff --git a/src/core/signer.ts b/src/core/signer.ts index 0a43b83..7829947 100644 --- a/src/core/signer.ts +++ b/src/core/signer.ts @@ -15,6 +15,7 @@ import { Keypair, PublicKey, Transaction, VersionedTransaction } from "@solana/w import bs58 from "bs58"; import type { ProvidedSignature } from "./context"; +import type { BazaarLog } from "./nft/bazaar"; /** Where a signed transaction must go. Mirrors the send paths the tools use themselves. */ export type SubmitRoute = @@ -41,6 +42,11 @@ export interface SignContext { step?: "final" | "intermediate"; /** Agent-facing description of what the transaction does, echoed back in `needs_signature`. */ summary?: Record; + /** + * A Baked Bazaar trade: what to report to its indexer once the transaction confirms. A local signer + * reports it itself; an external one echoes it so `submit_signed_tx` can report it instead. + */ + bazaarLog?: BazaarLog; } export type AnyTransaction = Transaction | VersionedTransaction; @@ -121,6 +127,8 @@ export type NeedsSignature = submit: SubmitRoute; step: "final" | "intermediate"; summary?: Record; + /** Pass back to `submit_signed_tx` unchanged; it tells the marketplace indexer after confirming. */ + bazaarLog?: BazaarLog; next: string; } | { @@ -177,12 +185,15 @@ export class ExternalSigner implements TxSigner { submit: ctx.submit, step, ...(ctx.summary ? { summary: ctx.summary } : {}), + ...(ctx.bazaarLog ? { bazaarLog: ctx.bazaarLog } : {}), next: `sign transactionBase64 with wallet ${this.publicKey.toBase58()} (do not modify it — any ` + `co-signatures would break; it was simulated, and its effect on this wallet checked against ` + `the request, which is what \`summary\` describes — let the wallet show the user what it ` + `does before they approve), then call submit_signed_tx with the signed ` + - `bytes and the same submit/blockhash/lastValidBlockHeight fields` + + `bytes and the same submit/blockhash/lastValidBlockHeight` + + (ctx.bazaarLog ? `/bazaarLog` : ``) + + ` fields` + (step === "intermediate" ? `. This is a prerequisite step: once it confirms, call the same tool again with the same ` + `arguments to continue.` diff --git a/src/core/submit.test.ts b/src/core/submit.test.ts index f2ca7ac..0a0e8df 100644 --- a/src/core/submit.test.ts +++ b/src/core/submit.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { Keypair, SystemProgram, @@ -8,8 +8,17 @@ import { } from "@solana/web3.js"; import bs58 from "bs58"; +import { BAKED_BAZAAR_API_URL } from "./config"; import { assertFullySigned, submitSignedTransaction } from "./submit"; +// A stand-in RPC for the send + confirm half; everything else in this file never reaches it. +const rpc = vi.hoisted(() => ({ + sendRawTransaction: vi.fn(async () => "SIG"), + confirmTransaction: vi.fn(async () => ({ value: { err: null } })), + getLatestBlockhash: vi.fn(async () => ({ blockhash: "x", lastValidBlockHeight: 1 })), +})); +vi.mock("./rpc", () => ({ getConnection: () => rpc, getSolanaConnection: () => rpc })); + const a = Keypair.generate(); const b = Keypair.generate(); const BLOCKHASH = bs58.encode(Buffer.alloc(32, 9)); @@ -62,3 +71,55 @@ describe("submitSignedTransaction input validation", () => { ); }); }); + +describe("submitSignedTransaction and the marketplace indexer", () => { + const bazaarLog = { type: "offer" as const, nftMint: b.publicKey.toBase58(), price: "5" }; + + function signedBase64(): string { + const tx = new Transaction({ + feePayer: a.publicKey, + blockhash: BLOCKHASH, + lastValidBlockHeight: 1, + }).add(SystemProgram.transfer({ fromPubkey: a.publicKey, toPubkey: b.publicKey, lamports: 1 })); + tx.sign(a); + return tx.serialize().toString("base64"); + } + + afterEach(() => { + vi.unstubAllGlobals(); + rpc.confirmTransaction.mockClear(); + }); + + it("reports bazaarLog with the signature once the transaction confirms", async () => { + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); + const res = await submitSignedTransaction({ + signedTransactionBase64: signedBase64(), + what: "NFT", + bazaarLog, + }); + expect(res.confirmed).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${BAKED_BAZAAR_API_URL}/log-transaction`); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ signature: "SIG", ...bazaarLog }); + }); + + it("reports nothing when the transaction does not confirm", async () => { + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); + rpc.confirmTransaction.mockRejectedValueOnce(new Error("block height exceeded")); + await expect( + submitSignedTransaction({ signedTransactionBase64: signedBase64(), what: "NFT", bazaarLog }), + ).rejects.toThrow(/could not be confirmed/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports nothing without a bazaarLog", async () => { + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); + await submitSignedTransaction({ signedTransactionBase64: signedBase64(), what: "stake" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/submit.ts b/src/core/submit.ts index 6d05c90..76861c0 100644 --- a/src/core/submit.ts +++ b/src/core/submit.ts @@ -9,6 +9,7 @@ import { confirmTx, submitSignedTx } from "./candyshop"; import { confirmSent } from "./confirm"; import { explorerTxUrl, solanaExplorerTxUrl } from "./config"; import { CookieMcpError } from "./errors"; +import { logTransaction, type BazaarLog } from "./nft/bazaar"; import { getConnection, getSolanaConnection } from "./rpc"; import type { AnyTransaction, SubmitRoute } from "./signer"; @@ -19,6 +20,8 @@ export interface SubmitSignedArgs { lastValidBlockHeight?: number; /** The action, for the "sent but unconfirmed" warning; echo `what` from `needs_signature`. */ what?: string; + /** Echo `bazaarLog` from `needs_signature`: reported to the Baked Bazaar indexer once confirmed. */ + bazaarLog?: BazaarLog; } export interface SubmitSignedResult { @@ -160,6 +163,10 @@ export async function submitSignedTransaction(args: SubmitSignedArgs): Promise[0]) => submitSignedTransaction(a)), From 0a677ebcc5f5143670021bd94bd2ae6a8d6185b2 Mon Sep 17 00:00:00 2001 From: Vichy724 <312135133+Vichy724@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:33:28 +0200 Subject: [PATCH 2/2] review: keep BazaarLog in the signer protocol, report only on cookie-rpc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BazaarLog` moves to `signer.ts`, so the generic signer seam and `submit.ts` no longer import a type from a venue module; `nft/bazaar.ts` imports it instead. `submit_signed_tx` reports to the indexer only for a transaction sent on the Cookie Chain RPC — the one route NFT transactions use — so a `bazaarLog` passed with a Solana submit cannot report a Solana signature. --- src/core/nft/bazaar.ts | 9 +-------- src/core/nft/index.ts | 3 +-- src/core/signer.ts | 13 ++++++++++++- src/core/submit.test.ts | 13 +++++++++++++ src/core/submit.ts | 9 +++++---- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/core/nft/bazaar.ts b/src/core/nft/bazaar.ts index b003f49..2ae2783 100644 --- a/src/core/nft/bazaar.ts +++ b/src/core/nft/bazaar.ts @@ -5,6 +5,7 @@ // it promptly (best-effort). import { BAKED_BAZAAR_API_URL } from "../config"; import { fetchJson } from "../http"; +import type { BazaarLog } from "../signer"; export interface BazaarCreator { address: string; @@ -116,14 +117,6 @@ export async function fetchCollectionStats(symbol: string): Promise { diff --git a/src/core/nft/index.ts b/src/core/nft/index.ts index 6dd86bc..d4918ad 100644 --- a/src/core/nft/index.ts +++ b/src/core/nft/index.ts @@ -21,7 +21,7 @@ import { rawToUi, uiToRaw, shortAddr } from "../format"; import { getConnection } from "../rpc"; import { requireSigner, ownPublicKey } from "../wallet"; import { signSendConfirm } from "../liquidity/send"; -import type { TxSigner } from "../signer"; +import type { BazaarLog, TxSigner } from "../signer"; import { AH_SELLER_FEE_BPS, escrowPaymentAccount, @@ -45,7 +45,6 @@ import { fetchCollectionStats, logTransaction, type BazaarListing, - type BazaarLog, type BazaarOffer, } from "./bazaar"; diff --git a/src/core/signer.ts b/src/core/signer.ts index 7829947..a9a98dc 100644 --- a/src/core/signer.ts +++ b/src/core/signer.ts @@ -15,7 +15,6 @@ import { Keypair, PublicKey, Transaction, VersionedTransaction } from "@solana/w import bs58 from "bs58"; import type { ProvidedSignature } from "./context"; -import type { BazaarLog } from "./nft/bazaar"; /** Where a signed transaction must go. Mirrors the send paths the tools use themselves. */ export type SubmitRoute = @@ -26,6 +25,18 @@ export type SubmitRoute = /** Candy Shop's own `/submit-tx` + `/confirm-tx` (Cookiescan-aggregator swaps). */ | { via: "candyshop"; pools: string[] }; +/** + * What the Baked Bazaar indexer is told about a confirmed marketplace transaction, besides its + * signature. Defined here, not in the NFT module, because it rides the signer protocol + * (`needs_signature` → `submit_signed_tx`) and this file must not depend on a venue. + */ +export interface BazaarLog { + type: "list" | "cancel-listing" | "buy" | "offer" | "cancel-offer" | "accept-offer"; + nftMint: string; + /** COOK lamports, decimal string. */ + price?: string; +} + /** What a flow tells the signer about the transaction it is about to sign. */ export interface SignContext { /** The action in the agent's words: "trade", "stake", "launch", "domain purchase". */ diff --git a/src/core/submit.test.ts b/src/core/submit.test.ts index 0a0e8df..2e581c2 100644 --- a/src/core/submit.test.ts +++ b/src/core/submit.test.ts @@ -116,6 +116,19 @@ describe("submitSignedTransaction and the marketplace indexer", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("reports nothing for a transaction sent on another route", async () => { + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); + const res = await submitSignedTransaction({ + signedTransactionBase64: signedBase64(), + submit: { via: "solana-rpc" }, + what: "NFT", + bazaarLog, + }); + expect(res.confirmed).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("reports nothing without a bazaarLog", async () => { const fetchMock = vi.fn(async () => new Response("{}")); vi.stubGlobal("fetch", fetchMock); diff --git a/src/core/submit.ts b/src/core/submit.ts index 76861c0..1ecf675 100644 --- a/src/core/submit.ts +++ b/src/core/submit.ts @@ -9,9 +9,9 @@ import { confirmTx, submitSignedTx } from "./candyshop"; import { confirmSent } from "./confirm"; import { explorerTxUrl, solanaExplorerTxUrl } from "./config"; import { CookieMcpError } from "./errors"; -import { logTransaction, type BazaarLog } from "./nft/bazaar"; +import { logTransaction } from "./nft/bazaar"; import { getConnection, getSolanaConnection } from "./rpc"; -import type { AnyTransaction, SubmitRoute } from "./signer"; +import type { AnyTransaction, BazaarLog, SubmitRoute } from "./signer"; export interface SubmitSignedArgs { signedTransactionBase64: string; @@ -163,8 +163,9 @@ export async function submitSignedTransaction(args: SubmitSignedArgs): Promise