# How Nullbook private deals work under the hood

Nullbook coordinates a negotiation offchain. Each trader’s browser verifies the terms and signs a single Solana transaction that exchanges a token for SOL on **mainnet**. PostgreSQL can record that both traders agreed; it cannot make a payment happen. The existing Helius Solana Privacy Rings (SPR) program executes both transfers. Nullbook deploys no custom settlement program.

For the screen-by-screen instructions, see the [trader guide](private-deals.md). For local services and environment setup, see [Run locally](../README.md#run-locally) and the [mainnet operations guide](mainnet-operations.md).

## Who controls which data?

| Component | Responsibility and boundary |
| --- | --- |
| Birdeye / market API | Supplies mainnet token discovery and reference prices. Neither its token list nor its price feed determines the agreed mainnet mint, quantity or execution price. |
| Connected Solana wallet / Privy | The connected wallet signs a single-use API challenge and authorizes its inbox binding. Privy provides the connection and login experience. The API authenticates the wallet signature and issues a wallet-bound bearer token valid for one hour. |
| Private wallet in the browser | Generates a separate seed, signs transfers and decrypts private notes locally through the SPR SDK. The downloaded backup encrypts that seed with AES-256-GCM and a passphrase-derived key (PBKDF2-SHA-256, 600,000 iterations). The same seed deterministically restores the inbox encryption key. Neither the API session nor a Privy login replaces this backup. |
| Fastify / PostgreSQL | Store the authenticated wallet-to-inbox directory and deal rooms. The service sees the connected accounts, registered owners, revisions, author, expiry, timestamps, readiness, approved transaction hashes, ciphertext size and final transaction ID. It does not need the spending keys or plaintext negotiated amounts. |
| Mainnet relay / Helius / Solana | The relay allows specific RPC, indexer and prover operations, pins the upstream network and keeps the project credential on the server. The client sends protected proof requests to the attested prover; the SDK checks the mainnet enclave policy. The chain still exposes signers, timing, fees, deposits and withdrawals. |

See the [wallet backup format](../apps/web/lib/private-wallet/backup.ts), [SDK wallet adapter](../apps/web/lib/private-wallet/sdk.ts), [relay](../apps/api/src/helius-mainnet.ts) and [session authentication](../apps/api/src/auth.ts). Leave `SOLANA_PROGRAM_ID` empty: it configures a separate legacy public-offer implementation. It does not select the program for private deals.

## Authentication and rooms

The [marketplace API](../apps/api/src/privacy-marketplace.ts) lives under `/solana/privacy/listings`. Anyone can read the paginated feed or an individual listing. Publishing or closing a listing, sending or withdrawing a bid, and viewing personal activity require a wallet session. A buyer sends a UUID and an encrypted, signed proposal; the quantity and SOL price are not database columns. Each bid binds the listing, mint, room ID, traders, revision and deadline so it cannot be reused for another listing. The public bid count includes all submissions. The service knows the bidder-to-listing relationship.

An open listing releases bid envelopes only to the seller and the corresponding bidder. In an auction the API also withholds the bidder identities and envelopes from the seller until the bidding deadline. This is a server-enforced reveal, not time-lock encryption. The seller can select one bid for negotiation until 24 hours after that deadline. Selection, closing and withdrawal serialize on the same listing row. Selection creates exactly one buyer-authored room inside the same database transaction; other bids do not become actionable deals. The selected room uses the existing two-signature settlement and expiry rules. The desk neither reserves assets nor charges a fee for listing, bidding or selecting.

Routes below are relative to Fastify. The [Next.js rewrite](../apps/web/next.config.ts) adds an external `/api` prefix for browser requests. A deal ID or invitation URL does not grant access. Directory lookup requires a session; a deal can be read or changed only by its maker or named counterparty.

| Method and path | Use |
| --- | --- |
| `POST /auth/challenge` then `POST /auth/verify` | Request a challenge for the connected Solana address; submit its nonce and wallet signature for a session. Use `Authorization: Bearer <token>` on the private endpoints. |
| `PUT /solana/privacy/key` | Enroll the current account’s inbox public key and private-wallet owner address. The connected wallet **and** the private owner must sign the same mainnet binding. A second enrollment cannot replace the original keys. |
| `GET /solana/privacy/key/:wallet` | Look up a participant. The client independently verifies both signatures before encrypting a proposal or trusting a payout owner. |
| `POST /solana/privacy/deals` | Create a room with a UUID, counterparty, expiry and encrypted terms. Both parties must already have enrolled inboxes. |
| `GET /solana/privacy/deals?page=1` · `GET /solana/privacy/deals/:id` | List the caller’s rooms (20 per page, without exchange packets), or fetch a single room with its packets. A nonparticipant receives the same not-found response as for a missing room. |
| `POST /solana/privacy/deals/:id/actions` | Advance a room using an action and the last room `version`. A conflicting write returns 409; fetch the room again before deciding what to do. |

The **revision** identifies the terms under negotiation. A counteroffer replaces the current encrypted terms and increments the revision. The **version** changes on every successful action; the database compares it before updating the room. Two requests based on the same version cannot both advance it. See the [route handlers](../apps/api/src/privacy-deals.ts), [shared record types](../packages/shared/src/privacy-deals.ts) and [database schema](../packages/db/src/index.ts).

| Room status | What can happen next |
| --- | --- |
| `negotiating` | Only the recipient of the latest terms can counter or accept. Either party can decline. Acceptance moves the room to `accepted`; it does not move funds. |
| `accepted` | Each browser checks its own private balance and submits `ready`. Once both have done so, the room becomes `ready`. The server records these declarations; it does not reserve the balances. |
| `ready` | The parties exchange encrypted proof packets and a signing window. Each submits `approve` with their private-wallet owner, the digest of the transaction message and their encrypted signature packet. Both approvals must identify distinct registered owners and the same digest. |
| `awaiting_confirmation` | Either trader can submit the signed transaction and request `confirm`. A matching finalized receipt is required to enter `settled`. Confirmation can still be checked after the negotiation expires because the transaction might already have landed. |
| `declined`, `expired`, `settled` | No new trade can be authorized in these states. The server permits a decline only before either an exchange packet or approval exists. Closing the browser or room cannot revoke a Solana signature already shared. |

## What a signature authorizes

The room encrypts the agreed mint, token quantity in base units, SOL quote in lamports, fee and recipient. Its signed context identifies the mainnet room, both connected accounts, the author, revision, expiry and record type or message ID. The sender signs the plaintext and then encrypts it for the two inboxes with a fresh nonce. This extra signature matters: both parties can decrypt the conversation, but neither can write a valid record in the other’s name. Hosts still see metadata; the counterparty sees the agreed terms. See [encrypted envelopes](../apps/web/lib/deals/envelopes.ts) and [room validation](../apps/web/lib/deals/room.ts).

To settle, three different checks take place:

1. **Build the two transfers.** The wallet checks the actual mint layout and its protocol-owned asset registration; unsupported token extensions are rejected. The seller constructs a proof for the token payment and the buyer constructs a proof for the SOL payment. Each endorses their own proof and its agreed terms. That endorsement alone cannot spend funds. The buyer then supplies the recent blockhash and last valid block height for the signing window.
2. **Verify the actual payouts and sign.** Each browser checks the proof endorsements and opens its expected incoming private output. It verifies the asset, amount and recipient against the output commitment. If a positive desk fee is configured, both check the treasury payout; its transaction-specific viewing key can also reveal the buyer’s change to the counterparty. Each browser constructs the same transaction and simulates it before signing its exact message bytes. It cannot reuse that signature for new terms or a new blockhash.
3. **Submit and confirm.** Either party can assemble the two signatures. The buyer’s private wallet is the fee payer. The transaction contains the seller’s transfer, the buyer’s transfer and a memo derived from the room ID and revision. Either both transfers execute or neither does. At confirmation, the API checks the mainnet genesis, successful finalized receipt, approved digest, signatures, expected program, tree, instruction layout, expiry and memo. It verifies the public transaction structure; the traders, not the API, verified the confidential payouts.

If a submission times out, look up the known transaction signature before starting another exchange. `409 not_finalized` means the API has not seen the matching finalized transaction yet; it does not prove that a broadcast failed. A mainnet RPC outage leaves the room unconfirmed. See the [settlement implementation](../apps/web/lib/privacy-settlement/index.ts), [wire format](../apps/web/lib/privacy-settlement/wire.ts) and [receipt verifier](../apps/api/src/privacy-receipt.ts).

## What has actually been tested?

| Evidence | What it establishes |
| --- | --- |
| [API tests](../tests/privacy-api.test.ts) and [wallet settlement tests](../apps/web/lib/privacy-settlement/seal.test.ts) | Exercise room access, competing writes, tampered terms, signature and receipt validation without requiring a funded trade. |
| [Two-browser negotiation](../tests/private-deals-browser/README.md) | Two isolated Chromium sessions restore backups, authenticate, negotiate and accept a revision using the real API and database. The harness substitutes Privy’s external provider prompts; it does not fund or submit the exchange. |
| [Mainnet connectivity check](mainnet-operations.md#check-your-connection-without-moving-funds) | Reads the cluster identity, executable programs and tree, then verifies indexer access, the mainnet enclave policy and circuit catalog. No funds are used. |

The production paths target mainnet. A funded mainnet browser/coordinator exchange, recovery withdrawal and nonzero-fee trade have not been demonstrated by these checks.
