Quickstart
This guide lends USDC at a fixed rate on Base with a TypeScript script. The script checks the API key, finds a market, gets a quote, previews the lend with a dry run, then signs, sends and tracks the transactions.
Prerequisites
- An API key with the
tradescope. The Tenor team issues keys: email contact@tenor.finance. A test key (tnr_sk_test_…) works for every step except sending. - Node.js 18 or later, for the built-in
fetch. - A wallet on Base with the USDC you want to lend, and some ETH for gas. This guide signs with a private key using viem, but any wallet that can send transactions works.
The script uses top-level await, so set the project up as an ES module:
mkdir tenor-quickstart && cd tenor-quickstart
npm init -y && npm pkg set type=module
npm install viem
export TENOR_API_KEY=tnr_sk_...
export PRIVATE_KEY=0x...
1. Set up the API helper
A successful response has the result in data, and an error has the details in error. This helper handles both:
const API = "https://trading-api.tenor.finance";
async function tenor<T = any>(method: "GET" | "POST", path: string, body?: object): Promise<T> {
const res = await fetch(`${API}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"X-Api-Key": process.env.TENOR_API_KEY!,
},
body: body && JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
throw new Error(`${json.error.code} (${json.error.treatment}): ${json.error.message}`);
}
return json.data;
}
Start by checking the key. GET /v1/access returns the key's organization, scopes and limits:
const access = await tenor("GET", "/v1/access");
console.log(access.mode, access.scopes); // "live" [ "read", "trade" ]
2. Find a market
GET /v1/markets returns every market, with its collateral tokens and the maturities open for new positions. Pick the USDC market that accepts cbBTC as collateral, and the market's earliest maturity:
const CHAIN_ID = 8453; // Base
const markets = await tenor("GET", `/v1/markets?chain_id=${CHAIN_ID}`);
const market = markets.find(
(m: any) =>
m.loan_token.symbol === "USDC" &&
m.collaterals.some((c: any) => c.token.symbol === "cbBTC"),
);
const maturity = market.maturities[0];
console.log(maturity.maturity_id, new Date(maturity.matures_at * 1000));
IDs are strings. Times are in Unix seconds.
3. Fetch a quote
POST /v1/quotes returns the current rate for an amount on one maturity, fees included. Amounts are whole-number strings in the token's smallest unit. USDC has 6 decimals, so 1,000 USDC is "1000000000":
const quote = await tenor("POST", "/v1/quotes", {
chain_id: CHAIN_ID,
maturity_id: maturity.maturity_id,
action: "lend",
size: { principal: "1000000000" },
});
console.log(quote.rate); // "0.0463" = 4.63% APR
console.log(quote.size.amount_at_maturity); // the amount the lend pays back at maturity
A quote isn't binding. If there isn't enough liquidity for the full amount, you get a 422 INSUFFICIENT_LIQUIDITY error, and details.available is the amount available. Set allow_partial: true to get a quote for that amount instead.
4. Preview the lend with a dry run
POST /v1/lend/prepare gets a new quote, builds the transactions and simulates them from your wallet. With dry_run: true, you get the full preview but nothing to send. This works even if the wallet doesn't hold the USDC yet:
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const preview = await tenor("POST", "/v1/lend/prepare", {
chain_id: CHAIN_ID,
wallet: account.address,
wallet_kind: "eoa",
maturity_id: maturity.maturity_id,
size: { principal: "1000000000" },
dry_run: true,
});
console.log(preview.status); // "preview_only"
console.log(preview.summary); // one line describing the lend
console.log(preview.preview.rate, preview.preview.balance_changes);
wallet_kind tells Tenor which kind of wallet signs, and sets how the transactions are built. A Safe, a smart account or an EIP-7702 wallet gets the whole lend as one batched transaction. See Wallet kinds.
Without a limit_rate, the lowest accepted rate is the quoted rate minus 0.10 percentage points. This limit is enforced onchain: if the rate would be lower, the transaction fails instead of filling.
5. Sign and send the transactions
Now prepare again without dry_run. For a regular wallet (eoa), steps lists the transactions in order: a token approval if one is needed, then the lend. Send them one at a time, and wait for each one to confirm:
import { createPublicClient, createWalletClient, http } from "viem";
import { base } from "viem/chains";
const publicClient = createPublicClient({ chain: base, transport: http() });
const walletClient = createWalletClient({ account, chain: base, transport: http() });
const payload = await tenor("POST", "/v1/lend/prepare", {
chain_id: CHAIN_ID,
wallet: account.address,
wallet_kind: "eoa",
maturity_id: maturity.maturity_id,
size: { principal: "1000000000" },
client_id: "quickstart-lend-1", // your own reference, echoed back and logged
});
let lendTx: `0x${string}` | undefined;
for (const step of payload.steps) {
const { to, data, value, gas } = step.transaction;
const hash = await walletClient.sendTransaction({
to,
data,
value: BigInt(value),
gas: BigInt(gas),
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error(`${step.description} reverted`);
if (step.role === "main") lendTx = hash;
}
The transactions are valid until validity.until_at. Leave a margin of validity.recommended_margin_s seconds. If you can't send them in time, prepare again.
6. Track the transaction
GET /v1/transactions/{tx_hash} returns the transaction status and the fills. Poll the endpoint every few seconds until status is confirmed or reverted and indexed is true. The new position then appears in the wallet's positions:
let tx;
do {
await new Promise((r) => setTimeout(r, 2000));
tx = await tenor("GET", `/v1/transactions/${lendTx}?chain_id=${CHAIN_ID}`);
} while (!(["confirmed", "reverted"].includes(tx.status) && tx.indexed));
console.log(tx.status, tx.fills);
const positions = await tenor(
"GET",
`/v1/accounts/${account.address}/positions?chain_id=${CHAIN_ID}&maturity_id=${maturity.maturity_id}`,
);
console.log(positions[0].balance); // principal, current value and amount at maturity
Full script
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
const API = "https://trading-api.tenor.finance";
const CHAIN_ID = 8453;
const SIZE = "1000000000"; // 1,000 USDC
async function tenor<T = any>(method: "GET" | "POST", path: string, body?: object): Promise<T> {
const res = await fetch(`${API}${path}`, {
method,
headers: { "Content-Type": "application/json", "X-Api-Key": process.env.TENOR_API_KEY! },
body: body && JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code} (${json.error.treatment}): ${json.error.message}`);
return json.data;
}
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: base, transport: http() });
const walletClient = createWalletClient({ account, chain: base, transport: http() });
// 1. Find a market and a maturity
const markets = await tenor("GET", `/v1/markets?chain_id=${CHAIN_ID}`);
const market = markets.find(
(m: any) => m.loan_token.symbol === "USDC" && m.collaterals.some((c: any) => c.token.symbol === "cbBTC"),
);
const maturity = market.maturities[0];
// 2. Quote
const quote = await tenor("POST", "/v1/quotes", {
chain_id: CHAIN_ID,
maturity_id: maturity.maturity_id,
action: "lend",
size: { principal: SIZE },
});
console.log(`Lending at ${(Number(quote.rate) * 100).toFixed(2)}% APR`);
// 3. Prepare
const payload = await tenor("POST", "/v1/lend/prepare", {
chain_id: CHAIN_ID,
wallet: account.address,
wallet_kind: "eoa",
maturity_id: maturity.maturity_id,
size: { principal: SIZE },
});
console.log(payload.summary);
for (const warning of payload.warnings ?? []) console.warn(warning.code, warning.message);
if (payload.status !== "ready") process.exit(0); // a test key returns preview_only
// 4. Sign and send each step, in order
let lendTx: `0x${string}` | undefined;
for (const step of payload.steps) {
const { to, data, value, gas } = step.transaction;
const hash = await walletClient.sendTransaction({ to, data, value: BigInt(value), gas: BigInt(gas) });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error(`${step.description} reverted`);
if (step.role === "main") lendTx = hash;
}
// 5. Track
let tx;
do {
await new Promise((r) => setTimeout(r, 2000));
tx = await tenor("GET", `/v1/transactions/${lendTx}?chain_id=${CHAIN_ID}`);
} while (!(["confirmed", "reverted"].includes(tx.status) && tx.indexed));
console.log(tx.status, tx.fills);
Run the script with npx tsx lend.ts from the project set up in Prerequisites.
Helpers for the guides
The guides use three helpers built from this script: tenor calls the API, send sends each step from a regular wallet (eoa), and track waits until Tenor has recorded the transaction.
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
export const API = "https://trading-api.tenor.finance";
export const CHAIN_ID = base.id; // 8453
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
export const wallet = account.address;
const publicClient = createPublicClient({ chain: base, transport: http() });
const walletClient = createWalletClient({ account, chain: base, transport: http() });
export async function tenor<T = any>(method: "GET" | "POST", path: string, body?: object): Promise<T> {
const res = await fetch(`${API}${path}`, {
method,
headers: { "Content-Type": "application/json", "X-Api-Key": process.env.TENOR_API_KEY! },
body: body && JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code} (${json.error.treatment}): ${json.error.message}`);
return json.data;
}
// Sends each step in order and returns the hash of the main transaction.
export async function send(payload: any): Promise<`0x${string}`> {
if (payload.status !== "ready") throw new Error("Dry run: nothing to send");
for (const w of payload.warnings ?? []) console.warn(w.code, w.message);
let hash: `0x${string}` | undefined;
for (const step of payload.steps) {
const { to, data, value, gas } = step.transaction;
hash = await walletClient.sendTransaction({ to, data, value: BigInt(value), gas: BigInt(gas) });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error(`${step.description} reverted`);
}
return hash!;
}
export async function track(hash: string) {
for (;;) {
const tx = await tenor("GET", `/v1/transactions/${hash}?chain_id=${CHAIN_ID}`);
if (["confirmed", "reverted"].includes(tx.status) && tx.indexed) return tx;
await new Promise((r) => setTimeout(r, 2000));
}
}
For a Safe, a smart account or an EIP-7702 wallet, send safe_tx or calls instead of steps. See Wallet kinds.
Next steps
- Concepts: how transactions, rate limits, deadlines and errors work.
- Post an offer at your rate: lend or borrow at your own rate instead of the market rate.
- Exit the lend:
POST /v1/resell/prepareresells the lend before maturity, andPOST /v1/redeem/preparecollects the lend after maturity. - API reference: every field in the responses above.