Skip to main content

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

EndpointOwnership proof
POST /quote/buyRequired — one signature per wallet supplied
POST /quote/sellRequired — one signature per wallet supplied
POST /bridge/quoteRequired — both sol_signature and eth_signature
POST /trade/submitNot 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>"
}
}
FieldTypeNotes
issued_atinteger (Unix seconds)When the proof was created. Always required.
sol_signaturestringBase64 Ed25519 signature. Required whenever sol_wallet is present.
eth_signaturestring0x-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_at as 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:

errorMeaning
ownership_proof_skewedissued_at is outside the freshness window — regenerate with a current timestamp.
ownership_proof_sol_invalidThe Solana signature didn't verify against sol_wallet.
ownership_proof_eth_invalidThe Ethereum signature didn't verify against eth_wallet.
ownership_proof_invalidThe 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.