Ownership Proof
Some endpoints need to know that the wallet you're quoting for is actually yours. An ownership proof is a short-lived signature, made with the wallet's own key, that proves control of the wallet before Treasures issues a quote. It carries no funds and authorizes no transfer. It only proves "I hold the key to this wallet, right now."
When it's required
Two different credentials appear on this API, and they are independent of each other. An ownership proof is per request and per wallet: it proves you hold the key. An integrator API key is per organisation: it identifies the caller. A route can want neither, either or both. See Integrator API Keys for the key model.
| Endpoint | API key | Ownership proof |
|---|---|---|
POST /quote/buy | Optional | Required, one signature per wallet supplied |
POST /quote/sell | Optional | Required, one signature per wallet supplied |
POST /quote/preview | Required, general tik_ key | Not required, the preview binds no wallet |
POST /bridge/quote | Optional | Required, both sol_signature and eth_signature |
POST /trade/submit | Optional, but send it: it is what attributes the trade to your organisation | Not required, the signed trade payload is itself the authorization |
GET /stocks/{ticker} | Required, general tik_ key | Not required |
GET /settlements | Required, tik_ or read-only trk_ | Not required |
Every other GET read (stocks, tickers, prices, quote status, bridge status, portfolio, trades) | Optional | Not required |
"Optional" means the route can be called anonymously. It does not mean a key you do send is ignored. A key is always verified, and a bad one is rejected rather than quietly downgraded to an anonymous call.
The two key-gated read routes are gated for different reasons. POST /quote/preview is the one quote route that binds no wallet at all, so there is nothing to prove ownership of, so the key identifies the caller instead. GET /stocks/{ticker} is gated because its profile, analyst, earnings and news blocks are served to contracted integrators only. Neither accepts a read-only trk_ reporting key.
Issuing a quote consumes upstream liquidity-provider quota, so the quote endpoints verify ownership up front. Trade submission doesn't need a separate proof: the transaction (or EIP-712 order) you submit is signed by the same key and commits to the exact action, which is a stronger guarantee than the proof itself.
The proof object
Attach the proof as an ownership_proof field alongside the wallet(s) in the request body:
{
"sol_wallet": "7xKXtg2C…",
"eth_wallet": "0xab5801a7…",
"ownership_proof": {
"issued_at": 1750000000,
"sol_signature": "<base64 Ed25519 signature>",
"eth_signature": "0x<EIP-191 signature>"
}
}
| Field | Type | Notes |
|---|---|---|
issued_at | integer (Unix seconds) | When the proof was created. Always required. |
sol_signature | string | Base64 Ed25519 signature. Required whenever sol_wallet is present. |
eth_signature | string | 0x-prefixed EIP-191 personal_sign signature. Required whenever eth_wallet is present. |
Send a signature for every wallet you include. Supplying only sol_wallet means you sign with Solana alone; supplying both wallets (which /bridge/quote always requires) means both signatures are required and both are verified.
What you sign: the canonical challenge
Every signature is made over the same canonical challenge, a UTF-8 string built by joining four lines with \n (newline):
treasures-finance-quote-v1
<issued_at>
<sol_wallet>
<eth_wallet>
- Line 1 is the fixed tag
treasures-finance-quote-v1. It scopes the signature to this exact purpose; sign anything else and it won't verify. - Line 2 is
issued_atas a decimal string, the same integer you put in the proof object. - Line 3 is your Solana wallet (base58, verbatim), or an empty line when you aren't supplying one.
- Line 4 is your Ethereum wallet, lowercased, or an empty line when you aren't supplying one. A checksummed (mixed-case) address will not verify.
For example, a Solana-only request issued at 1750000000 signs this exact string (the trailing blank line is the absent Ethereum wallet):
treasures-finance-quote-v1
1750000000
7xKXtg2C…
Freshness window
issued_at has to be recent. The server accepts a proof up to 5 minutes old and at most 30 seconds in the future (to tolerate minor clock drift). Anything outside that window is rejected with ownership_proof_skewed. Generate the proof immediately before each request rather than caching and reusing one.
Signing examples
Solana (Ed25519)
import { Keypair } from "@solana/web3.js";
import { ed25519 } from "@noble/curves/ed25519";
function buildChallenge(issuedAt: number, solWallet: string, ethWallet: string): string {
return ["treasures-finance-quote-v1", String(issuedAt), solWallet, ethWallet.toLowerCase()].join("\n");
}
const keypair = Keypair.fromSecretKey(secretKey);
const solWallet = keypair.publicKey.toBase58();
const issuedAt = Math.floor(Date.now() / 1000);
const challenge = buildChallenge(issuedAt, solWallet, ""); // empty eth line: Solana only
// noble expects the 32-byte Ed25519 seed, not the full 64-byte secret key
const signatureBytes = ed25519.sign(Buffer.from(challenge, "utf8"), keypair.secretKey.subarray(0, 32));
const solSignature = Buffer.from(signatureBytes).toString("base64");
const ownershipProof = { issued_at: issuedAt, sol_signature: solSignature };
Ethereum (EIP-191 personal_sign)
import { privateKeyToAccount } from "viem/accounts";
function buildChallenge(issuedAt: number, solWallet: string, ethWallet: string): string {
return ["treasures-finance-quote-v1", String(issuedAt), solWallet, ethWallet.toLowerCase()].join("\n");
}
const account = privateKeyToAccount(privateKey);
const ethWallet = account.address.toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
const challenge = buildChallenge(issuedAt, "", ethWallet); // empty sol line: Ethereum only
const ethSignature = await account.signMessage({ message: challenge });
const ownershipProof = { issued_at: issuedAt, eth_signature: ethSignature };
Signing for both chains in one request? Build the challenge with both wallet lines filled in, sign that single string with each key, and send both signatures under the same issued_at.
Ethereum verification recovers the signer from an externally owned account (personal_sign / ecrecover). Smart-contract (EIP-1271) wallets aren't supported; use an EOA.
Error codes
A failed proof returns 401 with one of:
error | Meaning |
|---|---|
ownership_proof_skewed | issued_at is outside the freshness window; regenerate with a current timestamp. |
ownership_proof_sol_invalid | The Solana signature didn't verify against sol_wallet. |
ownership_proof_eth_invalid | The Ethereum signature didn't verify against eth_wallet. |
ownership_proof_invalid | The proof was malformed, or no wallet was supplied to verify against. |
When both wallets are present, Solana is checked first, so a request with two bad signatures surfaces ownership_proof_sol_invalid first. Fix it and retry to see the Ethereum result.