solana-keygen new --no-bip39-passphrase -o test-wallet.json creates one. It plays your customer's wallet.
Devnet SOL
solana airdrop 1 $(solana-keygen pubkey test-wallet.json) --url devnet, or the Solana faucet. The wallet pays network fees and the rent for its new accounts, under 0.004 SOL.
Devnet USDC
The Circle faucet on Solana Devnet. Most sandbox strategies take this mint: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU. The examples deposit 25, so request enough or lower amount.
Node.js 20.18 or later
For the TypeScript steps.
Your organization also needs the strategy's provider activated. If a preview
returns 403 with provider_not_entitled, ask your SDP contact.
Install the TypeScript tools, then set your deployment URL and key:
Then add the test wallet helper. It signs exactly the bytes SDP builds, which
is what your customer's wallet will do in production:
TypeScripttest-wallet.ts
import { readFile } from "node:fs/promises";import { createKeyPairFromBytes, getAddressFromPublicKey, getBase64EncodedWireTransaction, getBase64Encoder, getTransactionDecoder, partiallySignTransaction,} from "@solana/kit";// Sandbox only: a local keypair stands in for your customer's wallet.const secretKey = new Uint8Array(JSON.parse(await readFile("test-wallet.json", "utf8")));const keyPair = await createKeyPairFromBytes(secretKey);export const ownerAddress = await getAddressFromPublicKey(keyPair.publicKey);/** Signs SDP's unsigned base64 transaction without changing it. */export async function signWithTestWallet(transactionBase64: string): Promise<string> { const transaction = getTransactionDecoder().decode(getBase64Encoder().encode(transactionBase64)); // Partial: it leaves any other signer's slot, such as your fee payer's, empty. const signed = await partiallySignTransaction([keyPair], transaction); return getBase64EncodedWireTransaction(signed);}
const SDP_API_URL = process.env.SDP_API_URL;if (!SDP_API_URL) throw new Error("Set SDP_API_URL to your beta or experimental API URL");const SDP_API_KEY = process.env.SDP_API_KEY;if (!SDP_API_KEY) throw new Error("Set SDP_API_KEY to your sandbox API key");/** Calls the Earn API: GET without a body, POST with one. Returns `data`. */export async function earnRequest(path: string, body?: unknown, idempotencyKey?: string) { const response = await fetch(`${SDP_API_URL}${path}`, { method: body === undefined ? "GET" : "POST", headers: { Authorization: `Bearer ${SDP_API_KEY}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.ok) { throw new Error(`SDP returned ${response.status}: ${await response.text()}`); } return (await response.json()).data;}/** Polls a movement until it is final: every second for a minute, then every five seconds. */export async function waitForMovement(movementId: string, timeoutMs = 300_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const { movement } = await earnRequest( `/v1/earn/external-wallet/movements/${encodeURIComponent(movementId)}`, ); if (movement.status === "finalized" || movement.status === "failed") return movement; const delayMs = Date.now() - startedAt < 60_000 ? 1_000 : 5_000; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error(`Movement ${movementId} is not final yet; keep tracking it`);}
const signedTransaction = await signWithTestWallet(built.transaction);// In production, save the idempotency key with the signed transaction before you send it,// and retry an uncertain response with both unchanged.const idempotencyKey = randomUUID();const { deposit } = await earnRequest( "/v1/earn/external-wallet/deposits", { transactionId: built.transactionId, signedTransaction }, idempotencyKey,);
SDP checks the signature against the exact bytes it built, records the
deposit, then broadcasts it.
Persist deposit.positionId with the selected strategy's withdrawalSlippage
in your backend. Save null explicitly when no floor is supported. Keep this
record for withdrawals even if the strategy leaves the catalog.
tokenValue is the position's live value in the deposit token. Share and
token amounts are equal here only because this vault's shares are still worth
one USDC each.
In production, the signing step runs in your app, not on your backend. With a
Wallet Standard wallet
and @solana/react (npm install @solana/react@8 @solana/kit@8 in your app),
sign the same base64 transaction and check that the wallet returned it
unchanged:
TypeScriptuse-sign-in-wallet.ts
import { useWalletAccountTransactionSigner } from "@solana/react";import { getBase64EncodedWireTransaction, getBase64Encoder, getTransactionDecoder,} from "@solana/kit";type WalletAccount = Parameters<typeof useWalletAccountTransactionSigner>[0];/** Signs SDP's base64 transaction in the customer's connected wallet. */export function useSignInWallet(account: WalletAccount) { // Use "solana:mainnet" for a production project. const signer = useWalletAccountTransactionSigner(account, "solana:devnet"); return async (transactionBase64: string): Promise<string> => { const unsigned = getTransactionDecoder().decode(getBase64Encoder().encode(transactionBase64)); // Wallet Standard signers may modify before signing, so check the result below. const [signed] = await signer.modifyAndSignTransactions([unsigned]); if (!signed) throw new Error("The wallet returned no signed transaction"); const unchanged = signed.messageBytes.length === unsigned.messageBytes.length && signed.messageBytes.every((byte, index) => byte === unsigned.messageBytes[index]); if (!unchanged) throw new Error("The wallet changed the transaction; build a new one"); return getBase64EncodedWireTransaction(signed); };}
Send the signed transaction to your backend, which submits it. Some wallets
add instructions before they sign. SDP refuses a changed transaction with
400, so check first and ask for a fresh build. Never broadcast it from the
wallet: SDP broadcasts after it records the deposit.