Skip to main content

Overview

A buy converts the user’s USDC into $GOLD on Solana. The flow is:
  1. QuotePOST /v1/buy → GRAIL returns a partially-signed transaction with a quote
  2. Co-sign — add the partner wallet signature and the user wallet signature
  3. SubmitPOST /v1/buy/:trade_id/submit (or broadcast directly to any Solana RPC)
  4. Wait — the indexer writes the Trade row after on-chain confirmation (~10–15s on devnet)
  5. FetchGET /v1/trades/:trade_id once the indexer has caught up
The quote endpoint is stateless — nothing is written to GRAIL’s database at quote time. The authoritative Trade record appears only after the transaction confirms on-chain.

Signers — the most important thing to get right

A buy transaction requires three signatures:
SignerWhat they sign forWho produces the signature
GRAIL (server)A memo instruction carrying the trade_id for the indexerAlready applied before GRAIL returns the tx
Partner walletwhitelisted_wallet authorization in the inti swap instructionYou add this on the partner’s behalf
User walletUSDC token authority (the account that’s sending USDC)You add this on the user’s behalf
If any of the three is missing, /submit returns 400 broadcast_failed with Missing signature for public key <pubkey>.

The partially-signed transaction

The quote response contains partially_signed_transaction — a base64 string. It’s a Solana Transaction that already has GRAIL’s signature attached. You:
  1. Deserialize it
  2. partialSign(partnerKeypair) and partialSign(userKeypair)
  3. Serialize it back to base64
The transaction bakes in a Solana recentBlockhash that expires in ~60 seconds. If you hold it too long, /submit returns broadcast_failed: Blockhash not found. Re-quote and try again.

Step 1 — Get a quote

BASE=https://grail-stack-dev.onrender.com

curl -s -X POST "$BASE/v1/buy" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
    "usdc_amount": 100,
    "slippage_bps": 50
  }' \
  > /tmp/quote.json

cat /tmp/quote.json
Response:
{
  "trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
  "side": "buy",
  "quote": {
    "usdc_amount": 100,
    "gold_amount": 0.0205,
    "price_per_troy_oz": 4872.84,
    "fee_bps": 50,
    "fee_usd": 0.50,
    "min_gold_out": 0.0204
  },
  "partially_signed_transaction": "AQAAAAABAAEC...<base64>..."
}

Slippage

Two ways to set the slippage floor:
  • slippage_bps (default 50 = 0.5%) — GRAIL computes min_gold_out = quoted_gold * (10000 - slippage_bps) / 10000
  • min_gold_out — absolute override. If supplied, slippage_bps is ignored.
On devnet the Pyth gold price updates infrequently (every few minutes). With tight slippage on a stale quote, trades can revert with SlippageExceeded on-chain. If you see this, widen to 100–300 bps.

Step 2 — Co-sign with partner + user

import { Transaction, Keypair } from "@solana/web3.js";
import bs58 from "bs58";

const PARTNER_SK = bs58.decode(PARTNER_SECRET_BASE58);
const USER_SK = bs58.decode(USER_SECRET_BASE58);

// Deserialize the partial-signed tx
const pstxB64 = quote.partially_signed_transaction;
const tx = Transaction.from(Buffer.from(pstxB64, "base64"));

// Co-sign with partner, then user
tx.partialSign(Keypair.fromSecretKey(PARTNER_SK));
tx.partialSign(Keypair.fromSecretKey(USER_SK));

// Serialize the fully-signed tx back to base64
const signedB64 = tx.serialize().toString("base64");
In a real deployment, the user signs on the client side and the partner’s server appends the partner signature before broadcast. You don’t have to co-sign in a single process — only the final serialized transaction matters.

Step 3 — Submit

Two options — both reach the same indexer.

Option A: Submit via GRAIL

curl -s -X POST "$BASE/v1/buy/$TRADE_ID/submit" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"signed_tx\":\"$SIGNED_B64\"}"
Response:
{
  "trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
  "tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58"
}
GRAIL runs Solana’s pre-flight simulation. If simulation fails (wrong signer, slippage blown up by a stale price, insufficient balance, etc.), you get 400 broadcast_failed with the reason in message.

Option B: Broadcast directly

import { Connection } from "@solana/web3.js";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const txHash = await connection.sendRawTransaction(Buffer.from(signedB64, "base64"));
The indexer picks up any confirmed transaction carrying the trade_id memo — it doesn’t matter who broadcasts.
If you want a transaction to actually land and revert on-chain (for testing the failure path), broadcast directly with skipPreflight: true. GRAIL’s /submit pre-flight would otherwise reject the tx before it lands.

Step 4 — Wait and fetch

The indexer writes the Trade row after on-chain confirmation, typically 10–15 seconds on devnet. Polling GET /v1/trades/:trade_id right after submit returns 404 trade_not_found until the indexer catches up.
sleep 15
curl -s "$BASE/v1/trades/$TRADE_ID" -H "x-api-key: $API_KEY"
Response (successful):
{
  "trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
  "side": "buy",
  "status": "confirmed",
  "usdc_amount": 100,
  "gold_amount": 0.020528,
  "price_per_troy_oz": 4872.84,
  "fee_bps": 50,
  "fee_usd": 0.50,
  "submitted_tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58",
  "created_at": "2026-04-17T10:18:30.000Z",
  "updated_at": "2026-04-17T10:18:30.000Z"
}
A status: "failed" row is written if the transaction landed on-chain but the inti program reverted (e.g., SlippageExceeded). On failures, usdc_amount reflects the input from the memo; gold_amount and the price/fee fields are 0 (the program never computed them).

End-to-end reference script

#!/usr/bin/env bash
set -euo pipefail

BASE=https://grail-stack-dev.onrender.com
USER_ID=gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3

# 1. Quote
curl -s -X POST "$BASE/v1/buy" \
  -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
  -d "{\"grail_user_id\":\"$USER_ID\",\"usdc_amount\":10,\"slippage_bps\":50}" \
  > /tmp/quote.json

TRADE_ID=$(jq -r .trade_id /tmp/quote.json)
PSTX=$(jq -r .partially_signed_transaction /tmp/quote.json)

# 2. Co-sign
SIGNED=$(npx tsx -e "
import { Transaction, Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
const partner = Keypair.fromSecretKey(bs58.decode('$PARTNER_SK'));
const user = Keypair.fromSecretKey(bs58.decode('$USER_SK'));
const tx = Transaction.from(Buffer.from(process.argv[1], 'base64'));
tx.partialSign(partner);
tx.partialSign(user);
process.stdout.write(tx.serialize().toString('base64'));
" "$PSTX")

# 3. Submit
curl -s -X POST "$BASE/v1/buy/$TRADE_ID/submit" \
  -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
  -d "{\"signed_tx\":\"$SIGNED\"}"

# 4. Wait + fetch
sleep 15
curl -s "$BASE/v1/trades/$TRADE_ID" -H "x-api-key: $API_KEY"

Common errors

ErrorLikely cause
400 kyc_level_insufficientUser’s KYC is not full
400 onchain_config_missingORO hasn’t set up your partner’s on-chain config yet. Contact ORO.
400 broadcast_failed: Blockhash not foundCo-signed too slowly — quote’s blockhash expired. Re-quote.
400 broadcast_failed: Missing signature for public key ...Didn’t co-sign with all three required signers
400 broadcast_failed: SlippageExceededPrice moved between quote and submit beyond min_gold_out. Widen slippage.
404 trade_not_found (right after submit)Indexer hasn’t written the row yet. Wait 15s and retry.
503 pricing_unavailablePyth oracle unreachable or returned data older than the staleness window

Next steps

Close the loop with Selling Gold, or jump to Redeeming Physical Gold for physical fulfillment.