A complete, zero-dependency JavaScript SDK (ESM) for interacting with the Solana Precog Markets program - a trustless pari-mutuel prediction market supporting native SOL, SPL Token, and Token-2022 denominations with on-chain multi-sig governance.
No Anchor required. This SDK uses raw
@solana/web3.jsTransactionInstructionobjects with hand-rolled Borsh serialization.
- Features
- Installation
- Quick Start
- Architecture
- Module Structure
- SPL Token / Token-2022 Markets
- Token-2022 Extension Support
- Dispute Resolution
- Multi-Sig Governance
- Market Lifecycle
- Account Discriminators
- Compute Budget & Priority Fees
- Error Handling
- Custom Program ID
- Serialization Utilities
- Requirements
- License
- 15 instruction builders covering the full program lifecycle
- 5 account decoders (Market, UserPosition, ProtocolConfig, MultisigAuthority, MultisigProposal)
- PDA derivation helpers for every account type
- High-level
PrecogMarketsClientwith auto-PDA resolution,sendTransaction, and batch/gPA queries - Discriminator-filtered RPC queries - all
getProgramAccountscalls use 8-byte account discriminatormemcmpfilters for efficient fetching - Low-level
BorshWriter/BorshReaderfor custom serialization needs - Full TypeScript declarations (
index.d.ts) - ESM-only (
"type": "module") - Peer dependency on
@solana/web3.js ^1.87- no other runtime deps
⚠️ Multisig governance is untested. The multisig instructions (createMultisig,createProposal,approveProposal,executeProposal) and their associated account decoders are included in the SDK but have not been tested against the on-chain program. The code is derived from the Rust program source and is believed to be structurally correct, but may contain bugs in serialization, account ordering, or argument encoding. Do not use in production without thorough testing. Contributions and test reports are welcome.
npm install precog-markets @solana/web3.jsimport { Connection, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { PrecogMarketsClient } from "precog-markets";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const client = new PrecogMarketsClient(connection);
// 1. Create a SOL market
const { market } = await client.createSolMarket({
payer: creator,
marketId: 1n,
title: "Will $PELF hit 10m MC by Jun?",
description: "Market cap milestone",
outcomeLabels: ["Yes", "No"],
resolutionDeadline: BigInt(Math.floor(Date.now() / 1000) + 86400 * 30),
});
// 2. Place a bet
const bettor = Keypair.generate();
const { position } = await client.placeSolBet({
bettor,
market,
outcomeIndex: 0, // "Yes"
amount: LAMPORTS_PER_SOL, // 1 SOL
});
// 3. Fetch & inspect
const mkt = await client.fetchMarket(market);
console.log(mkt.title); // "Will $PELF hit 10m MC by Jun?"
console.log(mkt.outcomeLabels); // ["Yes", "No"]
console.log(mkt.outcomePools); // [1000000000n, 0n]
console.log(mkt.statusName); // "Open"
// 4. Implied probabilities
const probs = PrecogMarketsClient.getImpliedProbabilities(
mkt.outcomePools, mkt.totalPool
);
console.log(probs); // [1, 0]src/
├── index.js # Barrel re-exports
├── index.d.ts # TypeScript declarations
├── constants.js # Program ID, seeds, discriminators, enums, errors
├── pda.js # PDA derivation for all account types
├── serialization.js # BorshWriter / BorshReader
├── accounts.js # Account decoders (Market, UserPosition, etc.)
├── instructions.js # Instruction builders (all 15 instructions)
└── client.js # High-level PrecogMarketsClient
| Export | Description |
|---|---|
PROGRAM_ID |
On-chain program address |
MarketStatus |
{ Open: 0, Resolved: 1, Finalized: 2, Voided: 3 } |
TokenDenomination |
{ NativeSol: 0, SplToken: 1, Token2022: 2 } |
ProposalActionTag |
Enum tags for multisig proposal actions |
ErrorCode / ErrorName |
Bidirectional error code ↔ name maps |
SEEDS |
PDA seed buffers |
DISCRIMINATORS |
Single-byte (u8) instruction discriminators |
ACCOUNT_DISCRIMINATORS |
8-byte account magic headers for memcmp filtering |
MAX_OUTCOMES, MAX_FEE_BPS, etc. |
Protocol limits |
Every PDA the program uses has a corresponding find*Address() function:
import { findMarketAddress, findPositionAddress } from "precog-markets";
const [marketPda, bump] = await findMarketAddress(authority, marketId);
const [positionPda] = await findPositionAddress(market, owner, outcomeIndex);| Function | Seeds |
|---|---|
findProtocolConfigAddress() |
["protocol_config"] |
findMarketAddress(authority, marketId) |
["market", authority, marketId_le] |
findVaultAddress(market) |
["vault", market] |
findVaultAuthorityAddress(market) |
["vault_authority", market] |
findPositionAddress(market, owner, index) |
["position", market, owner, index] |
findMultisigAddress(creator, nonce) |
["multisig", creator, nonce_le] |
findProposalAddress(multisig, proposalId) |
["proposal", multisig, proposalId_le] |
import { decodeMarket } from "precog-markets";
const accountInfo = await connection.getAccountInfo(marketAddress);
const market = decodeMarket(accountInfo.data);| Decoder | Returns | Status |
|---|---|---|
decodeMarket(data) |
MarketAccount |
Tested |
decodeUserPosition(data) |
UserPositionAccount |
Tested |
decodeProtocolConfig(data) |
ProtocolConfigAccount |
Tested |
decodeMultisigAuthority(data) |
MultisigAuthorityAccount |
|
decodeMultisigProposal(data) |
MultisigProposalAccount |
The Market account includes Token-2022 transfer fee metadata and creator fee split fields:
| Field | Type | Description |
|---|---|---|
hasTransferFee |
boolean |
Whether the token mint has a transfer fee extension |
transferFeeBps |
number |
Transfer fee in basis points (Token-2022 only) |
maxTransferFee |
bigint |
Maximum transfer fee in token base units |
creator |
PublicKey |
Wallet that created this market; receives creatorFeeBps of each winning claim |
creatorFeeBps |
number |
feeBps - defaultFeeBps; zero if no override was set |
Total fee = market.feeBps. On each winning claim:
- Protocol receives
protocolConfig.defaultFeeBpsworth → sent toprotocolConfig.treasury - Creator receives
market.creatorFeeBpsworth → sent tomarket.creator
When creating a market, if feeBpsOverride is set it must be ≥ the protocol defaultFeeBps. The excess becomes the creator's cut.
Each builder returns a TransactionInstruction. Pass your own accounts - the SDK never does PDA resolution at this level.
Note: Instruction data uses a single
u8byte discriminator (NOT a 4-byte or 8-byte Anchor discriminator).
import { placeBet, findProtocolConfigAddress } from "precog-markets";
const [protocolConfig] = findProtocolConfigAddress();
const ix = placeBet(
{ market, vault, position, bettor: bettor.publicKey, protocolConfig },
{ outcomeIndex: 0, amount: 1_000_000_000n }
);| Builder | Instruction | Status |
|---|---|---|
initializeProtocol(accounts, args) |
One-time protocol setup | Tested |
createMarket(accounts, args) |
Create a prediction market | Tested |
placeBet(accounts, args) |
Deposit SOL/tokens on an outcome | Tested |
resolveMarket(accounts, args) |
Single-sig resolve | Tested |
disputeResolve(accounts, args) |
Re-resolve during dispute window | Tested |
finalizeMarket(accounts) |
Permissionless crank after dispute window | Tested |
claimWinnings(accounts) |
Claim payout (fees split to treasury + creator) | Tested |
voidMarket(accounts) |
Void a market | Tested |
claimRefund(accounts) |
Refund on voided markets | Tested |
updateProtocolConfig(accounts, args) |
Admin config update | Tested |
harvestWithheldTokens(accounts) |
Harvest Token-2022 transfer fees | Tested |
createMultisig(accounts, args) |
Create M-of-N multisig | |
createProposal(accounts, args) |
Propose multisig action | |
approveProposal(accounts) |
Approve a proposal | |
executeProposal(accounts) |
Execute approved proposal |
The PrecogMarketsClient wraps everything with automatic PDA derivation and transaction sending:
// Default settings (computeUnitMargin: 1.1, priorityLevel: "Medium")
const client = new PrecogMarketsClient(connection);
// Custom settings
const client = new PrecogMarketsClient(connection, {
programId: customProgramId, // optional, defaults to PROGRAM_ID
computeUnitMargin: 1.2, // 20% headroom on CU estimates
priorityLevel: "High", // priority fee level
});
// Backward compatible - passing a PublicKey still works
const client = new PrecogMarketsClient(connection, customProgramId);Per-call overrides are also supported:
// Override priority level for a single call
await client.estimatePriorityFee(instructions, feePayer, { priorityLevel: "VeryHigh" });
// Override CU margin for a single call
await client.estimateComputeUnits(instructions, feePayer, { computeUnitMargin: 1.3 });// Transactional (sign + send)
await client.createSolMarket({ ... });
await client.placeSolBet({ ... });
await client.resolveMarket(authority, market, 0);
await client.finalizeMarket(payer, market);
await client.claimSolWinnings({ ... });
// Fetching
const market = await client.fetchMarket(address);
const positions = await client.getPositionsByOwner(owner);
const allMarkets = await client.getAllMarkets({ authority });
// Utilities
const payout = PrecogMarketsClient.calculatePayout(amount, winPool, totalPool, feeBps);
const probs = PrecogMarketsClient.getImpliedProbabilities(pools, total);For non-SOL markets, use createTokenMarket and placeTokenBet:
import { TOKEN_PROGRAM_ID, TokenDenomination } from "precog-markets";
const { market, vaultAuthority } = await client.createTokenMarket({
payer: creator,
marketId: 2n,
title: "USDC market",
description: "...",
outcomeLabels: ["Yes", "No"],
resolutionDeadline: deadline,
tokenMint: usdcMint,
tokenVault: vaultAta, // ATA owned by vaultAuthority PDA
tokenProgram: TOKEN_PROGRAM_ID,
denomination: TokenDenomination.SplToken, // 1
});
await client.placeTokenBet({
bettor,
market,
outcomeIndex: 0,
amount: 1_000_000n, // 1 USDC (6 decimals)
bettorTokenAccount: bettorAta,
tokenVault: vaultAta,
tokenMint: usdcMint,
tokenProgram: TOKEN_PROGRAM_ID,
});The on-chain program inspects a mint's Token-2022 extensions when a market is created and accepts only extensions that can't compromise vault accounting or payout math. The SDK mirrors this policy so you can check a mint client-side and fail fast with a descriptive reason instead of paying for a doomed transaction.
Allowed (market creation succeeds):
| Extension | Why it's safe |
|---|---|
TransferFeeConfig |
Fully supported — the program accounts for withheld fees in its math |
MintCloseAuthority |
Affects only the mint account, never token accounts or transfers |
DefaultAccountState |
The vault account is created during CreateMarket, so a frozen default fails cleanly and early |
InterestBearingConfig |
Interest only affects the UI-scaled amount; the raw u64 balance the program uses is unchanged |
MetadataPointer |
Informational only |
TokenMetadata |
Informational only |
Blocked (market creation is rejected with a specific error):
| Extension | Error | Reason |
|---|---|---|
TransferHook |
TransferHookNotAllowed |
Hook accounts can't be resolved by the token-moving instructions |
NonTransferable |
NonTransferableNotAllowed |
The program must move tokens between bettors and the vault |
PermanentDelegate |
PermanentDelegateNotAllowed |
A delegate could drain the vault |
ConfidentialTransferMint / ConfidentialTransferFeeConfig |
ConfidentialTransferNotAllowed |
Encrypted balances can't be read on-chain for vault accounting |
| any other extension | UnsupportedTokenExtension |
Not yet reviewed for safety |
import { validateMintForMarket, getTransferFeeConfig } from "precog-markets";
// From raw account bytes (connection.getAccountInfo(mint).data)
const info = await connection.getAccountInfo(tokenMint);
const result = validateMintForMarket(info.data);
if (!result.ok) {
console.error(`Unusable mint: ${result.error}`);
console.error("Blocked:", result.blocked.map((e) => e.name));
}
// Read the active transfer-fee schedule (null if the mint has no fee)
const fee = getTransferFeeConfig(info.data); // { feeBps, maxFee } | nullThe client wraps these with account fetching:
const check = await client.validateTokenMint(tokenMint); // MintValidation
const fee = await client.fetchTransferFeeConfig(tokenMint); // { feeBps, maxFee } | null
const exts = await client.fetchMintExtensions(tokenMint); // ParsedExtension[]You can also let createTokenMarket guard itself by passing validateMint: true, which fetches the mint and throws before sending if any extension is unsupported:
await client.createTokenMarket({
/* ...usual params... */
validateMint: true,
});After a market is resolved, a 24-hour dispute window begins. During this window the market authority can change the winning outcome or void the market entirely.
import { disputeResolve } from "precog-markets";
// Low-level instruction builder
const ix = disputeResolve(
{ market: marketPubkey, authority: authorityPubkey },
{ winningOutcome: 1 } // must differ from current winning outcome
);
// High-level client
await client.disputeResolve(authority, market, 1);Calling disputeResolve resets resolved_at to the current time, restarting a full 24-hour dispute window. The new outcome must be different from the current one (OutcomeUnchanged error otherwise).
Multisig note:
disputeResolveis only available to non-multisig authorities. Multisig-governed markets must use aVoidMarketproposal followed by a newResolveMarketproposal instead.
The authority can also void the market during the dispute window, refunding all positions:
await client.voidMarket(authority, market);
⚠️ This entire section covers untested functionality. The multisig governance instructions, PDA helpers, account decoders, and high-level client methods are included in the SDK but have not been integration-tested against the deployed on-chain program. The code is derived from the Rust program source and is believed to be structurally correct, but may contain bugs in serialization, account ordering, or argument encoding. Do not use in production without thorough testing.
Markets can be governed by an on-chain M-of-N multisig:
// Create a 2-of-3 multisig
const { multisig } = await client.createMultisig(
creator, 0n, 2, [signer1.publicKey, signer2.publicKey, signer3.publicKey]
);
// Create market with multisig authority
const { market } = await client.createSolMarket({
payer: creator,
authority: multisig, // Use multisig PDA as authority
authorityIsMultisig: true,
...
});
// Propose resolution (auto-approves for proposer)
const { proposal } = await client.createProposal({
proposer: signer1,
multisig,
market,
action: { type: "ResolveMarket", fields: { winningOutcome: 0 } },
});
// Second signer approves
await client.approveProposal(signer2, proposal, multisig);
// Anyone can execute once threshold is met
await client.executeProposal(anyPayer, proposal, multisig, market);| Action | Fields |
|---|---|
ResolveMarket |
{ winningOutcome: number } |
VoidMarket |
(none) |
UpdateDeadline |
{ newDeadline: bigint } |
UpdateFeeBps |
{ newFeeBps: number } |
AddSigner |
{ newSigner: PublicKey } |
RemoveSigner |
{ signer: PublicKey } |
ChangeThreshold |
{ newThreshold: number } |
Open ──→ Resolved ──→ (24h dispute) ──→ Finalized
│ ↑ │ │
│ │ │ disputeResolve() │
│ └──┘ (restarts window) ▼
│ claimWinnings()
└──→ Voided
│
▼
claimRefund()
- Open - Bets accepted until
resolutionDeadline - Resolved - Authority (or multisig) declares winning outcome
- Dispute window - 24h period; authority can call
disputeResolve()to change the outcome (resets the 24h clock), orvoidMarket()to cancel - Finalized - Anyone cranks
finalizeMarket; winners claim payouts - Voided (alternate) - Authority voids; all bettors get full refunds
Every on-chain account begins with an 8-byte magic header used for type identification:
| Account | Discriminator (hex) | ASCII |
|---|---|---|
| Market | 4d 41 52 4b 45 54 56 32 |
MARKETV2 |
| UserPosition | 50 4f 53 49 54 4e 56 31 |
POSITNV1 |
| ProtocolConfig | 50 52 4f 54 4f 43 4f 4c |
PROTOCOL |
| MultisigAuthority | 4d 55 4c 54 53 49 47 31 |
MULTSIG1 |
| MultisigProposal | 50 52 4f 50 4f 53 4c 31 |
PROPOSL1 |
The SDK uses these discriminators in all getProgramAccounts calls via memcmp filters for efficient RPC queries.
The SDK can estimate both compute units and priority fees for optimal transaction landing.
Simulates your transaction and returns a CU limit with a 1.1× safety margin:
const { estimatedUnits, instruction: cuIx } = await client.estimateComputeUnits(
[placeBetIx],
bettor.publicKey
);Estimates the optimal priority fee using the standard Solana RPC method getRecentPrioritizationFees. Works with any RPC provider (Helius, Triton, QuickNode, etc.). Extracts writable accounts from your instructions for account-aware fee estimation.
const { priorityFee, instruction: feeIx } = await client.estimatePriorityFee(
[placeBetIx],
bettor.publicKey
);
// priorityFee is in microLamports per compute unitDefault priority level is "Medium" (50th percentile). Override with:
await client.estimatePriorityFee(instructions, feePayer, { priorityLevel: "High" });For provider-specific APIs (e.g., Helius getPriorityFeeEstimate, Triton percentile API), pass a custom feeEstimator in the constructor. It should return microLamports per compute unit:
const client = new PrecogMarketsClient(connection, {
feeEstimator: async (connection, instructions, feePayer, { priorityLevel }) => {
// Your provider-specific logic here
// Return a number (microLamports per CU)
return 50_000;
},
});Get both compute unit limit and priority fee instructions in one call:
const result = await client.estimateTransactionFees(
[placeBetIx],
bettor.publicKey
);
// result.instructions = [cuLimitIx, cuPriceIx, ...originalInstructions]
const tx = new Transaction().add(...result.instructions);This runs estimateComputeUnits and estimatePriorityFee in parallel and returns everything ready to go.
For the best transaction landing rate, use sendSmartTransaction - it handles CU estimation, priority fees, signing, and optimized sending in one call:
const { signature, estimatedUnits, priorityFee } = await client.sendSmartTransaction(
[placeBetIx],
[bettor] // first signer = fee payer
);Under the hood this:
- Estimates compute units (simulation x 1.1)
- Estimates priority fee from recent on-chain data ("Medium" by default)
- Prepends
setComputeUnitLimit+setComputeUnitPriceinstructions - Signs and sends with
skipPreflight: true, maxRetries: 0
You can also use sendRawTransaction directly for pre-built transactions:
const sig = await client.sendRawTransaction(signedTx);
// Defaults: skipPreflight: true, maxRetries: 0The SDK exports all 67 program error codes:
import { ErrorCode, ErrorName } from "precog-markets";
ErrorCode[6]; // "MarketNotOpen"
ErrorName["MarketNotOpen"]; // 6Every function and the client accept an optional programId override:
import { PrecogMarketsClient, findMarketAddress } from "precog-markets";
const customProgramId = new PublicKey("YourProgram...");
const client = new PrecogMarketsClient(connection, customProgramId);
const [market] = await findMarketAddress(authority, 1n, customProgramId);The BorshWriter/BorshReader classes are exported for advanced use:
import { BorshWriter, BorshReader } from "precog-markets";
const writer = new BorshWriter();
writer.writeU64(42n).writeString("hello").writeBool(true);
const buf = writer.toBuffer();
const reader = new BorshReader(buf);
console.log(reader.readU64()); // 42n
console.log(reader.readString()); // "hello"
console.log(reader.readBool()); // true- Node.js ≥ 18
@solana/web3.js^1.87.0 (peer dependency)@solana/spl-token(optional peer, for creating ATAs)
MIT