Overview
A buy converts the user’s USDC into $GOLD on Solana. The flow is:
- Quote —
POST /v1/buy → GRAIL returns a partially-signed transaction with a quote
- Co-sign — add the partner wallet signature and the user wallet signature
- Submit —
POST /v1/buy/:trade_id/submit (or broadcast directly to any Solana RPC)
- Wait — the indexer writes the
Trade row after on-chain confirmation (~10–15s on devnet)
- Fetch —
GET /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:
| Signer | What they sign for | Who produces the signature |
|---|
| GRAIL (server) | A memo instruction carrying the trade_id for the indexer | Already applied before GRAIL returns the tx |
| Partner wallet | whitelisted_wallet authorization in the inti swap instruction | You add this on the partner’s behalf |
| User wallet | USDC 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:
- Deserialize it
partialSign(partnerKeypair) and partialSign(userKeypair)
- 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
| Error | Likely cause |
|---|
400 kyc_level_insufficient | User’s KYC is not full |
400 onchain_config_missing | ORO hasn’t set up your partner’s on-chain config yet. Contact ORO. |
400 broadcast_failed: Blockhash not found | Co-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: SlippageExceeded | Price 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_unavailable | Pyth 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.