Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/core/liquidity/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ 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,
tx: Transaction,
signer: TxSigner,
cosigners: Signer[],
what = "transaction",
extra?: Pick<SignContext, "bazaarLog">,
): Promise<string> {
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash("confirmed");
tx.recentBlockhash = blockhash;
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/core/nft/bazaar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -118,7 +119,7 @@ export async function fetchCollectionStats(symbol: string): Promise<BazaarCollec

// Best-effort: tell the indexer about a signed+confirmed tx so listings/offers update without waiting
// for its own tx scan. Never throws — indexing lag must not fail a successful on-chain action.
export async function logTransaction(payload: Record<string, unknown>): Promise<void> {
export async function logTransaction(payload: { signature: string } & BazaarLog): Promise<void> {
try {
await fetchJson(`${base()}/log-transaction`, {
method: "POST",
Expand Down
46 changes: 29 additions & 17 deletions src/core/nft/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -386,9 +386,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(),
Expand Down Expand Up @@ -424,8 +422,10 @@ export async function cancelListing(args: { mint: string }): Promise<NftTxResult
price: BigInt(listing.price),
side: "sell",
});
const signature = await sendNftTx(conn, [cancel], signer);
await logTransaction({ signature, type: "cancel-listing", nftMint: args.mint });
const signature = await sendNftTx(conn, [cancel], signer, {
type: "cancel-listing",
nftMint: args.mint,
});
return {
signature,
action: "cancel_listing",
Expand Down Expand Up @@ -482,8 +482,11 @@ export async function buyNft(args: {
}),
);

const signature = await sendNftTx(conn, ixs, signer);
await logTransaction({ signature, type: "buy", nftMint: args.mint, price: price.toString() });
const signature = await sendNftTx(conn, ixs, signer, {
type: "buy",
nftMint: args.mint,
price: price.toString(),
});
return {
signature,
action: "buy",
Expand Down Expand Up @@ -520,8 +523,11 @@ export async function makeOffer(args: {
if (fund) ixs.push(fund);
ixs.push(buildPublicBuyIx({ buyer, nftMint: mint, price }));

const signature = await sendNftTx(conn, ixs, signer);
await logTransaction({ signature, type: "offer", nftMint: args.mint, price: price.toString() });
const signature = await sendNftTx(conn, ixs, signer, {
type: "offer",
nftMint: args.mint,
price: price.toString(),
});
return {
signature,
action: "make_offer",
Expand Down Expand Up @@ -557,8 +563,10 @@ export async function cancelOffer(args: { mint: string }): Promise<NftTxResult>
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",
Expand Down Expand Up @@ -615,9 +623,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(),
Expand Down Expand Up @@ -658,12 +664,18 @@ async function requireActiveListing(mint: string): Promise<BazaarListing> {
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<string> {
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;
}
13 changes: 13 additions & 0 deletions src/core/signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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], {
Expand Down
24 changes: 23 additions & 1 deletion src/core/signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,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". */
Expand All @@ -41,6 +53,11 @@ export interface SignContext {
step?: "final" | "intermediate";
/** Agent-facing description of what the transaction does, echoed back in `needs_signature`. */
summary?: Record<string, unknown>;
/**
* 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;
Expand Down Expand Up @@ -121,6 +138,8 @@ export type NeedsSignature =
submit: SubmitRoute;
step: "final" | "intermediate";
summary?: Record<string, unknown>;
/** Pass back to `submit_signed_tx` unchanged; it tells the marketplace indexer after confirming. */
bazaarLog?: BazaarLog;
next: string;
}
| {
Expand Down Expand Up @@ -177,12 +196,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` +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts with main: the security fix in 6b0dd22 rewrote this same next string (it now tells the wallet to show the user what the tx does), so the PR can't merge as is. When you rebase, keep main's wording and just add the /bazaarLog bit.

Prompt for an agent
First verify the conflict is real: in cookie-mcp, run `git fetch origin && git merge-tree --write-tree --name-only origin/main HEAD` on branch feat/bazaar-log-external. If it reports no conflicts, stop and report back instead of changing anything.

If it does: rebase feat/bazaar-log-external onto origin/main. In src/core/signer.ts ExternalSigner.signTransaction, keep main's new `next` text (the "any co-signatures would break; it was simulated, and its effect on this wallet checked against the request..." wording) and insert only the conditional `/bazaarLog` before " fields". Resolve README.md by keeping both sides' bullets. Do not change any other wording.

Verify: `yarn test` passes (lint, format, typecheck, unit, smoke = 57 tools), and src/core/signer.test.ts "echoes a marketplace bazaarLog" still matches the `next` regex.

(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.`
Expand Down
76 changes: 75 additions & 1 deletion src/core/submit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import {
Keypair,
SystemProgram,
Expand All @@ -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));
Expand Down Expand Up @@ -62,3 +71,68 @@ 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 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);
await submitSignedTransaction({ signedTransactionBase64: signedBase64(), what: "stake" });
expect(fetchMock).not.toHaveBeenCalled();
});
});
10 changes: 9 additions & 1 deletion src/core/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { confirmTx, submitSignedTx } from "./candyshop";
import { confirmSent } from "./confirm";
import { explorerTxUrl, solanaExplorerTxUrl } from "./config";
import { CookieMcpError } from "./errors";
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -160,6 +163,11 @@ export async function submitSignedTransaction(args: SubmitSignedArgs): Promise<S
if (args.what === "bridge" && confirmed) {
messageId = await bridgeMessageId(conn, signature);
}
// The NFT tool would have told the indexer after its own confirm; do the same here. NFT
// transactions only ever go out on the Cookie Chain RPC, so nothing else is reported.
if (args.bazaarLog && confirmed && route.via === "cookie-rpc") {
await logTransaction({ signature, ...args.bazaarLog });
}
return {
signature,
confirmed,
Expand Down
Loading
Loading