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
| Endpoint | Ownership proof |
|---|---|
POST /quote/buy | Required — one signature per wallet supplied |
POST /quote/sell | Required — one signature per wallet supplied |
POST /bridge/quote | Required — both sol_signature and eth_signature |
POST /trade/submit | Not required — the signed trade payload is itself the authorization |
All GET reads (stocks, prices, status, portfolio, trades) | Not required — public |
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.