Skip to main content
This page takes the private key of your key’s associatedWallet from nothing to fully trading in five steps. Every code block is real ethers v6 against the live testnet — paste it in, swap your API key + private key, run.
Don’t have an API key yet? Keys are issued by PredictStreet ops — they are never self-service. Email your integration manager (or partners@predictstreet.com) with the four items in API keys → Getting a key and you’ll receive a testnet ps_live_… key plus the IP-allowlist + KYC prerequisites for orders:write and vault:write scopes.

Prerequisites

WhatWhere
Node ≥ 20 with ethers@6 installednpm i ethers
PredictStreet API keyIssued by ops — see API keys. Format: ps_live_<keyId>_<secret>
Private key of the key’s associatedWalletStored in your secrets manager. The wallet must have completed KYC tier 1 before write-scope keys are issued.
Testnet RPChttps://rpc.ab.testnet.adifoundation.ai/
API base URLhttps://core.api.dev.predictstreet.sde.adifoundation.ai
import {
  Wallet, JsonRpcProvider, Contract, MaxUint256,
  parseUnits, parseEther, randomBytes,
} from 'ethers';

const RPC  = 'https://rpc.ab.testnet.adifoundation.ai/';
const BASE = 'https://core.api.dev.predictstreet.sde.adifoundation.ai';

// Load both secrets from your secrets manager.
const API_KEY = process.env.PS_API_KEY!;            // ps_live_<keyId>_<secret>
const eoaPk   = process.env.PS_ASSOCIATED_WALLET_PK!;

// Canonical testnet addresses (chainId 99999)
const VAULT_FACTORY = '0xFFFBE32fF7B8a2AFD4FA2Af0C480FE59206A8ba3';
const USDC          = '0x9bC8244c0F531F27fe3636FFdAf2C586d11241e1';
const CTF_EXCHANGE          = '0xc3c197e42AfE809a7f34D3a7eE6aDE0cF7613D2b'; // binary markets
const CTF_EXCHANGE_NEG_RISK = '0xB1A9274D2a9bd8a8CDd4D836e4f5273d3870211a'; // 3+-outcome (neg-risk) markets

const provider = new JsonRpcProvider(RPC);
const eoa      = new Wallet(eoaPk, provider);
console.log('associatedWallet:', eoa.address);

// Tiny helper — every authenticated call ships the key header.
const auth = { 'X-Api-Key': API_KEY, 'Content-Type': 'application/json' };

1. Mint testnet USDC to your EOA

MockCollateral exposes a public mint(address, uint256) on testnet — mint as much as you want. Note: USDC needs to land in your EOA first; the vault deposit moves it from EOA → vault in a later step.
const usdc = new Contract(
  USDC,
  [
    'function mint(address,uint256)',
    'function balanceOf(address) view returns (uint256)',
    'function approve(address,uint256) returns (bool)',
  ],
  eoa,
);

await (await usdc.mint(eoa.address, parseUnits('1000', 6))).wait();
console.log('USDC balance:', (await usdc.balanceOf(eoa.address)).toString());
You also need a small ADI balance for gas. On testnet grab it from the public faucet — ~5 ADI to your EOA covers deploy + deposit + a handful of trades.

2. Get your vault address

The platform deploys your vault automatically the moment your associated wallet clears KYC tier 1 — you do not call VaultFactory.deployVault yourself, and you do not pay the deploy gas. KYC clearance is a hard prerequisite for any orders:write / vault:write key, so by the time you have a working API key, your vault is either deployed or about to be. Poll GET /api/me/vault until deployed: true:
async function waitForVault(timeoutMs = 60_000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const r = await fetch(`${BASE}/api/me/vault`, { headers: auth });
    const me = await r.json();
    // deployStatus is one of: not_started | pending | building |
    //   submitted | confirmed | skipped_existing | failed_permanent
    // See /concepts/vaults/auto-deploy#deploystatus-values for
    // semantics. `deployed: true` means VaultFactory.vaultOf(eoa)
    // returns a non-zero address on chain.
    if (me.deployed) return me.vaultAddress;
    await new Promise(r => setTimeout(r, 1500));
  }
  throw new Error('vault not auto-deployed within 60s — check KYC status');
}

const vault = await waitForVault();
console.log('your vault:', vault);
Save vault. It’s the address you’ll send as maker in every signed order, and the verifyingContract in vault-side EIP-712 domains (splits / withdrawals).
WebSocket alternative. Subscribe to user_activity on /ws/user and watch for vault.deploy_confirmed instead of polling — see WebSocket — User events. The poll above is shown for the quickstart because it’s one-file copy-pasteable.

3. Approve + deposit USDC into the vault

// One-time MaxUint256 approval is the standard pattern — saves gas
// on every future deposit. Tighten if your security model needs it.
await (await usdc.approve(vault, MaxUint256, { gasLimit: 100_000n })).wait();

const v = new Contract(
  vault,
  ['function depositERC20(address token, uint256 amount)'],
  eoa,
);
await (await v.depositERC20(
  USDC,
  parseUnits('500', 6),               // 500 USDC into the vault
  { gasLimit: 600_000n },
)).wait();
After ~1 block the platform reflects the deposit and your /api/me/balances will show available = 500 USDC. The on-chain approve step stays on your side regardless of auto-deploy — ERC-20 transferability is owner-only by design. See Deposits for the full lifecycle and Deposit limits for the daily / weekly / monthly caps applied to your vault.
Per-EOA deposit limits are initialised by the platform automatically alongside the vault deploy. By the time GET /api/me/vault returns deployed: true, the limits are already registered and the deposit call succeeds.

4. Place orders

EIP-712 helper

Every order is signed against the on-chain CTFExchange Order struct (11 fields). See EIP-712 signing for the full type table. Helper:
Binary vs neg-risk domain. The verifyingContract you sign against depends on the market: 2-outcome binary markets settle on CTFExchange, 3+-outcome (neg-risk) markets settle on PredictStreetNegRiskCtfExchange. The market response carries negRiskEligible: boolean — branch on it before signing or you’ll get bad_signature rejections at order placement:
const verifyingContract = market.negRiskEligible
  ? CTF_EXCHANGE_NEG_RISK
  : CTF_EXCHANGE;
The Order struct itself is identical between the two — only the domain verifyingContract differs.
const DOMAIN = {
  name: 'PredictStreet', version: '1',
  chainId: 99999, verifyingContract: CTF_EXCHANGE,
};
const TYPES = { Order: [
  { name: 'salt',          type: 'uint256' },
  { name: 'maker',         type: 'address' },
  { name: 'signer',        type: 'address' },
  { name: 'taker',         type: 'address' },
  { name: 'tokenId',       type: 'uint256' },
  { name: 'makerAmount',   type: 'uint256' },
  { name: 'takerAmount',   type: 'uint256' },
  { name: 'expiration',    type: 'uint256' },
  { name: 'feeRateBps',    type: 'uint256' },
  { name: 'side',          type: 'uint8'   },
  { name: 'signatureType', type: 'uint8'   },
]};

async function signOrder(opts: {
  side: 'buy' | 'sell',
  price: string,                 // e.g. "0.42" — decimal USDC
  qty: string,                   // e.g. "10"   — decimal outcome qty
  tokenId: string,               // from market.yesTokenId / market.noTokenId
}) {
  const priceWei    = parseUnits(opts.price, 6);
  const qtyWei      = parseUnits(opts.qty,   6);
  const notionalWei = (priceWei * qtyWei) / 1_000_000n;
  const salt        = BigInt('0x' + Buffer.from(randomBytes(32)).toString('hex'));

  const struct = {
    salt,
    maker:        vault,                                // your vault
    signer:       eoa.address,                          // your EOA
    taker:        '0x0000000000000000000000000000000000000000',
    tokenId:      BigInt(opts.tokenId),
    makerAmount:  opts.side === 'buy' ? notionalWei : qtyWei,
    takerAmount:  opts.side === 'buy' ? qtyWei      : notionalWei,
    expiration:   0n,                                   // 0 = no on-chain expiry (recommended)
    feeRateBps:   0n,                                   // taker fee charged on-chain
    side:         opts.side === 'buy' ? 0 : 1,
    signatureType: 1,                                   // VAULT
  };
  const signature = await eoa.signTypedData(DOMAIN, TYPES, struct);
  return { struct, signature };
}

Pull a real tokenId

const market = await fetch(
  `${BASE}/api/markets/NC26-BIN-83479265`,
).then(r => r.json());

const yesTokenId = market.yesTokenId;   // outcome '0'
const noTokenId  = market.noTokenId;    // outcome '1'

LIMIT BUY (rests in the book)

const { struct, signature } = await signOrder({
  side: 'buy', price: '0.45', qty: '10', tokenId: yesTokenId,
});

const resp = await fetch(`${BASE}/api/orders/place`, {
  method: 'POST',
  headers: auth,
  body: JSON.stringify({
    marketId:      'NC26-BIN-83479265',
    side:          'buy',
    outcome:       '0',                      // YES
    price:         '0.45',
    quantity:      '10',
    nonce:         struct.salt.toString(),
    expiry:        0,                        // = struct.expiration
    maker:         vault,                    // = struct.maker
    signature,
    type:          'limit',                  // (default — can be omitted)
    timeInForce:   'gtc',                    // (default for limit — can be omitted)
    clientOrderId: `bot-${Date.now()}`,
  }),
}).then(r => r.json());

console.log(resp);
// { orderId, status: 'OPEN' | 'FILLED' | 'PARTIAL' | 'REJECTED',
//   filledQty, remainingQty, trades: [...] }
HTTP 201 does not mean “order accepted by the matcher.” The endpoint returns 201 Created as soon as core-api validates the request envelope and forwards it — even when the matcher later rejects it. Always branch on the body’s status field, not the HTTP code:
if (resp.status === 'REJECTED') {
  // resp.code is the reason — e.g. 'bad_signature',
  // 'market_not_open', 'insufficient_balance'. Fix and retry.
  console.error('rejected:', resp.code, resp.message);
}

MARKET BUY (best-effort, IOC)

MARKET requires timeInForce: ioc or fok. The price field still travels (it acts as a slippage cap — you only fill at price-or-better).
const { struct, signature } = await signOrder({
  side: 'buy', price: '0.99', qty: '5', tokenId: yesTokenId,
});

const resp = await fetch(`${BASE}/api/orders/place`, {
  method: 'POST',
  headers: auth,
  body: JSON.stringify({
    marketId: 'NC26-BIN-83479265', side: 'buy', outcome: '0',
    price: '0.99', quantity: '5',
    nonce: struct.salt.toString(), expiry: 0, maker: vault, signature,
    type: 'market',
    timeInForce: 'ioc',                      // or 'fok' for all-or-nothing
  }),
}).then(r => r.json());

Cancelling

// Single
await fetch(`${BASE}/api/orders/cancel`, {
  method: 'POST',
  headers: auth,
  body:    JSON.stringify({ orderId: resp.orderId }),
});

// Everything you have open
await fetch(`${BASE}/api/orders/cancel-all`, {
  method:  'POST',
  headers: auth,
  body:    '{}',
});

5. Split USDC → YES + NO so you can SELL

A SELL order must reference outcome ERC-1155 tokens that already sit in your vault. You get them by depositing USDC and then calling vault.splitPosition(...) with a backend co-signature. The backend returns the co-sig from POST /api/vault/split-signature; you sign the same struct yourself, then submit on-chain.
// 1. Get the backend co-signature
const sig = await fetch(`${BASE}/api/vault/split-signature`, {
  method:  'POST',
  headers: auth,
  body:    JSON.stringify({ marketId: 'NC26-BIN-83479265', amount: '50' }),
}).then(r => r.json());

// 2. Sign the same SplitPosition struct yourself
const SPLIT_TYPES = { SplitPosition: [
  { name: 'kind',            type: 'uint8'    },
  { name: 'collateralToken', type: 'address'  },
  { name: 'conditionId',     type: 'bytes32'  },
  { name: 'partition',       type: 'uint256[]' },
  { name: 'amount',          type: 'uint256'  },
  { name: 'salt',            type: 'uint256'  },
  { name: 'deadline',        type: 'uint256'  },
]};

const split = {
  kind:            sig.kind,
  collateralToken: sig.collateralToken,
  conditionId:     sig.conditionId,
  partition:       sig.partition.map(BigInt),
  amount:          BigInt(sig.amount),
  salt:            BigInt(sig.salt),
  deadline:        BigInt(sig.deadline),
};
const ownerSig = await eoa.signTypedData(
  { name: 'PredictStreetVault', version: '1', chainId: 99999, verifyingContract: vault },
  SPLIT_TYPES,
  split,
);

// 3. Submit on-chain
const vaultC = new Contract(
  vault,
  ['function splitPosition(uint8,address,bytes32,uint256[],uint256,uint256,uint256,bytes,bytes)'],
  eoa,
);
await (await vaultC.splitPosition(
  split.kind, split.collateralToken, split.conditionId,
  split.partition, split.amount, split.salt, split.deadline,
  ownerSig, sig.backendSig,
  { gasLimit: 1_500_000n },
)).wait();
After this confirms the vault holds 50 YES + 50 NO ERC-1155. Now SELL of either side works exactly like BUY but with side: 'sell':
const { struct, signature } = await signOrder({
  side: 'sell', price: '0.55', qty: '20', tokenId: yesTokenId,
});

await fetch(`${BASE}/api/orders/place`, {
  method: 'POST',
  headers: auth,
  body: JSON.stringify({
    marketId: 'NC26-BIN-83479265', side: 'sell', outcome: '0',
    price: '0.55', quantity: '20',
    nonce: struct.salt.toString(), expiry: 0, maker: vault, signature,
  }),
});

Order types — what you can ask for

The platform supports the following combinations end-to-end:
typetimeInForceBehaviour
limit (default)gtc (default)Rests in the book until filled, expired, or cancelled.
limitiocFill what’s available at price-or-better right now; cancel any unfilled remainder.
limitfokEither fill the entire quantity at price-or-better immediately, or cancel without any partial fill.
marketiocTake the best resting prices up to your price slippage cap; cancel any unfilled remainder.
marketfokAll-or-nothing market — fill the entire quantity within the slippage cap, or cancel.
marketgtcRejected with code: invalid_tif — MARKET orders cannot rest.
price is always required, even for MARKET — it acts as the slippage cap. The matcher will only consume liquidity at price or better. expiry (= EIP-712 expiration) defaults to 0 (no on-chain expiry). A non-zero value is fine for off-chain matching but races async on-chain settlement; if the deadline passes between match and submit, the on-chain leg reverts with OrderExpired(). Recommend keeping 0 unless you specifically need a hard TTL well above worst-case settlement latency.

Verifying state after each step

// On-chain
const usdcInVault = await usdc.balanceOf(vault);

// Off-chain (the platform mirrors on-chain state with ~1-block lag)
const balances = await fetch(`${BASE}/api/me/balances`, {
  headers: auth,
}).then(r => r.json());

const positions = await fetch(`${BASE}/api/me/positions`, {
  headers: auth,
}).then(r => r.json());

const open = await fetch(`${BASE}/api/orders/open`, {
  headers: auth,
}).then(r => r.json());

Common pitfalls

SymptomCauseFix
GET /api/me/vault keeps returning deployed: false past the typical 15-second windowKYC not yet approved (single_wallet) or sub-account not yet onboarded (multi_wallet)See Backend auto-deploy → What if auto-deploy doesn’t fire
depositERC20 reverts with selector 0x87138d5cNotInitialized() — your code raced ahead of the auto-deploy job (deploy-tx mined but the cap-init tx hasn’t yet)Wait one block and retry; or rely on GET /api/me/vault returning deployed: true before depositing
400 bad_request on /api/orders/place with no detailAn unknown field in the body — forbidNonWhitelistedOnly send the whitelisted set above (no extra fields)
400 bad_signature on order placementmakerVaultFactory.vaultOf(signer) on chainSend your vault address, not the EOA
code: invalid_tiftype: market with timeInForce: gtcUse ioc or fok for MARKET
code: insufficient_position on SELLThe outcome token isn’t in your vaultRun step 6 (split) first
code: insufficient_funds on BUYNot enough USDC available in vaultTop up via step 3
code: market_not_openMarket is PROPOSED / PAUSED / RESOLVEDPick a market with status: OPEN
MatchFailed(reason: 0xc56873ba) on settlementOrderExpired() — your expiration slipped between match and on-chain submitSign with expiration: 0

Where to go next

EIP-712 signing reference

Full struct + Python / Go / Rust signing examples.

Order lifecycle

PENDING → OPEN → PARTIAL → FILLED state machine + on-chain settlement.

Vault contract reference

Full ABI, dual-sig flow, emergency withdraw, digest invalidation.

Time-in-force semantics

GTC / IOC / FOK behaviour in detail.