Developers

Provider Interface

The TypeScript contracts that connect a Pair Market to the outside world: FinancialInstrumentProvider for Pair Instruments, the Pair Router that plans and executes acquisitions, and LaunchAdapter for the token venue.

Overview

Three boundaries keep PairStreet’s product code independent of any venue or issuer:

PairStreet integration contracts
ContractFileImplementations
FinancialInstrumentProvidersrc/server/adapters/providers/types.tsListedInstrumentProvider (registry), SplOnchainProvider (spl-onchain)
Pair Routersrc/server/pair/router.tsplanAcquisition, executeAcquisition
LaunchAdaptersrc/server/adapters/launch/types.tsPumpLaunchAdapter (live), a development adapter

Code reaches a provider only through getProvider(id) in the registry. A ProviderProviderA source of a Pair Instrument behind the common provider interface: an onchain venue, a tokenized-asset issuer, an RFQ desk or a broker route. is selected by the adapterKey recorded on its catalog row. Product code never imports a provider SDK and never branches on a provider name.

FinancialInstrumentProvider

src/server/adapters/providers/types.tsinterface
/**
 * FinancialInstrumentProvider: the abstraction every Pair Instrument source implements.
 *
 * A provider knows how to describe an instrument, prove which onchain asset is canonical,
 * price it, say who may hold it, and plan/execute acquiring it. PairStreet product code
 * only ever talks to this interface (via the registry), never to a provider SDK directly.
 */
export interface FinancialInstrumentProvider {
  readonly id: string;
  readonly kind: ProviderKind;
  /** Mock providers exist only in development and their output must never be presented as real assets. */
  readonly isMock: boolean;

  getInstruments(): Promise<ProviderInstrument[]>;
  getInstrument(ref: string): Promise<ProviderInstrument | null>;
  /** The onchain representation holders actually receive. Null if the instrument has none (yet). */
  getCanonicalAsset(ref: string): Promise<CanonicalAsset | null>;
  getQuote(req: QuoteRequest): Promise<InstrumentQuote>;
  checkEligibility(req: EligibilityRequest): Promise<EligibilityResult>;
  getPurchaseRoute(req: QuoteRequest): Promise<PurchaseRoute>;
  /** Execute an acquisition plan. Live providers require a configured vault signer. */
  purchase(req: PurchaseRequest): Promise<PurchaseExecution>;
  getTransferRestrictions(ref: string): Promise<TransferRestrictions>;
  getInstrumentStatus(ref: string): Promise<InstrumentStatusReport>;
  getIssuerDocuments(ref: string): Promise<IssuerDocument[]>;
}

Methods

id, kind
The provider id from the catalog and its ProviderKind.
isMock
True only for development providers. A mock result is never written to a live ledger row; executeAcquisition refuses a mock execution on a live plan.
getInstruments()
Every instrument the provider offers, as ProviderInstrument records. The catalog sync writes them to pair_instruments.
getInstrument(ref)
One instrument by the provider’s own reference (pair_instruments.provider_reference), or null.
getCanonicalAsset(ref)
The onchain asset holders actually receive: mint, decimals, token program and verification level. Null while an instrument has no connected settlement asset. The spl-onchain adapter cross-checks decimals against Jupiter’s token list.
getQuote(req)
Prices converting inputAmountRaw of the settlement asset (USDC by default) into the instrument. Returns raw amounts as strings, an implied USD price, price impact and the quote source.
checkEligibility(req)
Verdict for a viewer country. Implementations apply the instrument’s rules through evaluateEligibility and may add provider-specific checks.
getPurchaseRoute(req)
The acquisition route: mode, steps, the quote it was planned on, availability and blockers. A route that is not executable says why in blockers instead of failing.
purchase(req)
Executes a route for a Pair Vault address. Live providers require a configured vault signer; until one exists they raise ProviderNotEnabledError.
getTransferRestrictions(ref)
Whether the Pair Asset is transferable, a readable summary, and its token program.
getInstrumentStatus(ref)
Current InstrumentStatus with a reason and timestamp. The spl-onchain adapter derives it from whether a 100 USDC route exists.
getIssuerDocuments(ref)
Issuer links: website, terms, prospectus, attestation, docs.

Types

src/server/adapters/providers/types.tstypes
export type ProviderInstrument = {
  ref: string; // provider's own reference (stored as pair_instruments.provider_reference)
  slug: string;
  name: string;
  shortName: string;
  symbol: string;
  description: string;
  category: InstrumentCategory;
  subcategory?: string;
  country: string;
  region: Region;
  issuer: string;
  assetType: AssetType;
  icon: string;
  searchTerms: string;
  documentationUrl?: string;
};

export type CanonicalAsset = {
  network: "solana";
  mint: string;
  decimals: number;
  tokenProgram: "spl-token" | "token-2022" | "unknown";
  verification: InstrumentVerification;
  verifiedBy?: string;
};

export type QuoteRequest = {
  ref: string;
  inputMint: string; // settlement asset (USDC by default)
  inputAmountRaw: bigint;
  slippageBps?: number;
};

export type InstrumentQuote = {
  ref: string;
  inputMint: string;
  inputAmountRaw: string;
  outputAmountRaw: string;
  outputDecimals: number;
  /** USD per whole instrument unit implied by the quote. */
  priceUsd: number;
  priceImpactPct: number;
  source: string; // e.g. "jupiter", "dev-mock"
  quotedAt: string;
  isMock: boolean;
  raw?: unknown;
};

export type EligibilityRequest = { ref: string; country: string | null };
export type EligibilityResult =
  | { eligible: true }
  | { eligible: false; code: "REGION_RESTRICTED" | "UNKNOWN_REGION" | "KYC_REQUIRED" | "INSTRUMENT_UNAVAILABLE"; reason: string };

export type RouteStep = { kind: "swap" | "mint" | "rfq" | "transfer" | "simulated"; label: string; venue?: string };

export type PurchaseRoute = {
  mode: RouteMode;
  available: boolean;
  steps: RouteStep[];
  quote?: InstrumentQuote;
  /** Reasons the route cannot execute right now (missing signer, no liquidity, provider paused…). */
  blockers: string[];
};

export type PurchaseRequest = { route: PurchaseRoute; vaultAddress: string; inputAmountRaw: bigint };
export type PurchaseExecution = {
  outputAmountRaw: bigint;
  executionPriceUsd: number;
  txSignature: string | null;
  isMock: boolean;
};

export type TransferRestrictions = { transferable: boolean; summary: string; tokenProgram?: string };
export type InstrumentStatusReport = { status: InstrumentStatus; reason?: string; checkedAt: string };
export type IssuerDocument = { title: string; url: string; kind: "website" | "terms" | "prospectus" | "attestation" | "docs" };
src/lib/domain.tsenums
// src/lib/domain.ts
export const PROVIDER_KINDS = [
  "tokenized_bonds",
  "tokenized_securities",
  "tokenized_fund",
  "real_estate",
  "commodity",
  "onchain_dex_route",
  "pair_registry",
  "dev_mock",
] as const;

export const ROUTE_MODES = ["ONCHAIN_SWAP", "PROVIDER_API", "RFQ", "ISSUER_MINT", "BROKER_ROUTE", "DEV_SIMULATED"] as const;

export const INSTRUMENT_STATUSES = ["active", "paused", "coming_soon", "provider_unavailable", "deprecated"] as const;

export const INSTRUMENT_VERIFICATION = [
  "dev_mock", // no real asset, development only
  "listed", // listed for pairing; onchain settlement asset not connected yet
  "issuer_listed", // mint published by issuer, routable, not independently verified by PairStreet
  "aggregator_verified", // verified by a token-list aggregator (e.g. Jupiter verified tag)
  "pairstreet_verified", // reviewed by PairStreet (manual process, future)
] as const;

Raw amounts cross the interface as bigint on requests and as decimal strings on quotes, so a quote serializes to JSON without loss. Prices are USD per whole instrument unit.

Pair Router

The Pair RouterPair RouterThe execution layer that converts a Pair Vault's settlement balance into an acquisition plan for the Pair Asset and hands it to the right executor. sits between a Pair Market and its provider. It turns “this Pair Market holds X of the settlement asset” into an AcquisitionPlan, then hands an executable plan to the right executor. Design and route selection are covered in Pair Router.

src/server/pair/router.tsPair Router
// src/server/pair/router.ts (types and signatures)
export type AcquisitionPlan = {
  pairMarketId: string;
  instrumentRef: string;
  providerId: string;
  settlementMint: string;
  inputAmountRaw: string;
  mode: RouteMode;
  route: PurchaseRoute;
  executable: boolean;
  blockers: string[];
  ledgerMode: LedgerMode;
  plannedAt: string;
};

export type PlanInput = {
  pairMarketId: string;
  marketLedgerMode: LedgerMode;
  instrument: { providerReference: string; providerId: string; status: string; referencePriceUsd: number; decimals: number; shortName: string };
  settlementMint: string;
  inputAmountRaw: bigint;
};

export async function planAcquisition(input: PlanInput): Promise<AcquisitionPlan>;
export async function executeAcquisition(plan: AcquisitionPlan, vaultAddress: string): Promise<PurchaseExecution>;

planAcquisition

The router asks the instrument’s provider for a purchase route, then adds its own checks. Every blocker is collected, de-duplicated and returned with the plan. A plan is executable only when the route is available and the blocker list is empty.

Checks the Pair Router adds to every provider route
Router checkBlocker added
Instrument status is not activeInstrument is <status>
The route mode has no executor in this buildRoute mode <mode> has no executor in this build
A simulated route outside developmentSimulated routes are disabled outside development
A simulated route on a live Pair MarketA live Pair Market can never settle through a simulated route

The plan’s ledgerMode is dev_simulated only for DEV_SIMULATED routes and live otherwise, so every settlement row inherits the correct Ledger modeLedger modeEvery financial ledger row carries live or development. Production reads and writes live rows only..

executeAcquisition

executeAcquisition throws if the plan is not executable, listing its blockers. A DEV_SIMULATED plan returns the quoted output with no transaction signature. Any other mode calls the provider’s purchase with the plan’s route, the vault address and the input amount. A mock execution returned for a live plan is refused.

Pair Router route modes
Route modeStatusDetail
ONCHAIN_SWAPPlanned livePlanned with live Jupiter quotes for SPL instruments. Execution activates with Pair Vault custody.
PROVIDER_APIActivates per routeInterface defined. Registry instruments report this mode while their route connects.
RFQArchitectureInterface defined; activates per provider.
ISSUER_MINTArchitectureInterface defined; activates per provider.
BROKER_ROUTEArchitectureInterface defined; activates per provider.
DEV_SIMULATEDDevelopment onlyExecutes on the development network only and writes dev_simulated ledger rows.

LaunchAdapter

LaunchAdapter is the token-side boundary. It is the only code that talks to launch and liquidity infrastructure. Pump is the first venue, implemented by PumpLaunchAdapter on the official @pump-fun/pump-sdk v2.0.0. Which Pump instructions each method builds is documented in Pump and Solana.

src/server/adapters/launch/types.tsinterface
/**
 * LaunchAdapter: the ONLY place PairStreet talks to launch/liquidity infrastructure.
 * Pump is the first venue. React components never import an adapter; they call
 * PairStreet API routes, which call services, which call the adapter.
 */
export interface LaunchAdapter {
  readonly venue: "pump";
  /** `dev_simulated` adapters never touch a chain; their results are flagged in every ledger row. */
  readonly ledgerMode: LedgerMode;

  /** Build the launch. Live: returns a partially-signed transaction (mint keypair signed) for the creator wallet. */
  prepareLaunch(input: PrepareLaunchInput): Promise<PreparedLaunch>;
  /** Verify the launch landed and return canonical venue addresses. */
  confirmLaunch(input: ConfirmLaunchInput): Promise<ConfirmedLaunch>;

  getMarket(mint: string): Promise<VenueMarket | null>;
  getGraduationStatus(mint: string): Promise<GraduationStatus>;
  quoteTrade(input: QuoteTradeInput): Promise<TradeQuote>;
  buildBuyTransaction(input: BuildTradeInput): Promise<PreparedTrade>;
  buildSellTransaction(input: BuildTradeInput): Promise<PreparedTrade>;
  /** Venue-native trade history. Production ingestion goes through the indexer, not this call. */
  getTrades(mint: string, opts: { limit: number; before?: string }): Promise<VenueTrade[]>;

  /** Creator-fee split (Pump fee-sharing config): build the setup tx for a launched mint. Live only. */
  buildFeeSetupTransaction?(input: { mint: string; creator: string; payer?: string; freshCurve?: boolean }): Promise<{ transactionBase64: string; lastValidBlockHeight: number }>;
  /** Read the on-chain creator-fee split for a mint. Null when no sharing config exists yet. */
  readFeeSplit?(mint: string): Promise<OnchainFeeSplit | null>;
  /** Permissionless: pay out accrued creator fees to the configured shareholders. */
  buildDistributeFeesTransaction?(input: { mint: string; payer: string }): Promise<{ transactionBase64: string; lastValidBlockHeight: number; distributableLamports: string; canDistribute: boolean }>;
}
prepareLaunch
Builds the launch: create_v2 plus create_fee_sharing_config with the mint keypair pre-signed, then follow-up transactions for the fee split and an optional first buy.
confirmLaunch
Fetches the signature on-chain, checks it did not fail, checks the mint and creator are in it, and checks the bonding curve exists.
getMarket / getGraduationStatus
Reads the bonding-curve account: reserves, supply, completion, creator. Graduation status is bonding or graduated from the curve’s complete flag.
quoteTrade
Bonding-curve quote with Pump’s fees embedded, plus the PairStreet trading fee (0.5% of the SOL side) in protocolFeeRaw.
buildBuyTransaction / buildSellTransaction
Unsigned versioned transactions with buy_v2 or sell_v2 and the fee transfer, for the trader’s wallet to sign.
getTrades
Venue trade history. Production ingestion runs through the indexer.
buildFeeSetupTransaction
update_fee_shares: creator 5,000 bps, treasury 5,000 bps. Rejects a split that is already locked or a caller who is not the coin’s creator.
readFeeSplit
Decodes the mint’s Pump fee-sharing config: address, admin, adminRevoked and shareholders. Null if none exists.
buildDistributeFeesTransaction
Permissionless payout of accrued creator fees to the shareholders, with the distributable amount and whether a payout is possible now.

Adding a provider

A provider plugs in without touching the Pair Router, the settlement engine or the reward engine.

  1. Step 01PROVIDERS
    Register the provider

    Add a catalog row with its id, kind and adapterKey.

    src/server/catalog/instruments.tsplaceholder values
    // src/server/catalog/instruments.ts
    export const PROVIDERS: CatalogProvider[] = [
      // ...
      {
        id: "<provider-id>",
        name: "<Provider name>",
        kind: "tokenized_bonds",          // a ProviderKind
        adapterKey: "spl-onchain",        // "registry" | "spl-onchain" | a new key
        website: "https://<issuer-site>",
        description: "<one line: what the provider issues>",
        isMock: false,
      },
    ];
  2. Step 02adapterKey
    Choose or write the adapter

    If the instrument is a liquid SPL or Token-2022 asset on Solana, reuse spl-onchain. Otherwise implement FinancialInstrumentProvider in a new class under src/server/adapters/providers/ and map a new adapterKey to it in the registry.

    src/server/adapters/providers/registry.ts
    // src/server/adapters/providers/registry.ts
    function build() {
      const map = new Map<string, FinancialInstrumentProvider>();
      for (const p of PROVIDERS) {
        if (p.adapterKey === "registry") {
          map.set(p.id, new ListedInstrumentProvider(p.id));
        } else if (p.adapterKey === "spl-onchain") {
          map.set(p.id, new SplOnchainProvider(p.id, p.kind));
        }
      }
      return map;
    }
    
    export function getProvider(id: string): FinancialInstrumentProvider;
    export function listProviders(): FinancialInstrumentProvider[];
  3. Step 03INSTRUMENTS
    Add its instruments

    Each instrument records providerId, its provider reference, mint, decimals, token program, verification level, status, eligibility rules, transfer summary, issuer documents and a reference price with its source. An instrument with a mint is never recorded as listed; the catalog test enforces it.

  4. Step 04syncCatalog
    Sync the catalog

    syncCatalog upserts providers, instruments and eligibility rules idempotently, preserving instrument ids and their statistics. Tested for repeat runs.

  5. Step 05blockers
    Report what the route needs

    Until the route can execute, getPurchaseRoute returns available: false with explicit blockers, and purchase raises ProviderNotEnabledError naming what is missing. The instrument is selectable for pairing as soon as its status is active; settlement follows when its route executes.

ProviderNotEnabledError

A provider that knows exactly what it still needs says so in a typed error. The missing list names each requirement.

src/server/adapters/providers/types.tserror
// src/server/adapters/providers/types.ts
export class ProviderNotEnabledError extends Error {
  constructor(
    message: string,
    readonly missing: string[],
  ) {
    super(message);
    this.name = "ProviderNotEnabledError";
  }
}

The API layer maps ProviderNotEnabledError (and the launch-side AdapterNotAvailableError) to HTTP 501 with code NOT_AVAILABLE and passes the missing list through. This is the response the spl-onchain adapter’s purchase produces today:

JSONHTTP 501
{
  "error": {
    "code": "NOT_AVAILABLE",
    "message": "Live Pair acquisitions are not enabled in this build.",
    "missing": [
      "Pair Vault custody program or multisig signer",
      "Settlement executor service with Jupiter swap execution"
    ]
  }
}