Check vaulted holdings
Recognize tokens a wallet owns in Conviction with a public, read-only HTTP API.
Count tokens held in the vault
A wallet's ERC-20 balance excludes tokens it deposited in Conviction. The vault still records that wallet as the position owner. Use this API to include vaulted principal in a token-holder check, without asking the holder to withdraw.
| Position or balance | Included |
|---|---|
| Flexible lock or No early exit lock | Yes, while the principal remains in the vault. |
| Matured position awaiting release | Yes. Maturity alone does not move the tokens. |
| Lock funded by another wallet | Yes, for the beneficiary who owns the position. |
| Released or exited position | No. Historical amounts are not current holdings. |
| Liquid wallet balance or unclaimed rewards | No. Read these separately when your policy needs them. |
Request a wallet and token
Send a GET request to /api/vault-holdings with chainId, owner and token. Use the ERC-20 contract address, not its symbol. No API key or wallet connection is required. Browser requests are supported through public CORS.
curl --get 'https://www.conviction.locker/api/vault-holdings' \
--data-urlencode 'chainId=4663' \
--data-urlencode 'owner=0xB5dE4Dd7e787ed59E4d060EF4a36dEefEb9eB51A' \
--data-urlencode 'token=0xB6ff957E31286F748989Cbf69183b1b5e8d921A8'| Query parameter | Value |
|---|---|
| chainId | Required decimal chain ID. It must match the API deployment. |
| owner | Required nonzero wallet address whose positions should count. |
| token | Required nonzero ERC-20 address. Any token deposited in the configured vault can be checked. |
| cursor | Optional opaque nextCursor from the previous response. Keep the other parameters unchanged. |
The first request selects a block. Subsequent pages stay at that block, so deposits and withdrawals during pagination cannot shift the balance being counted. Each page checks that the snapshot is still canonical.
Read the complete balance
| Response fields | Meaning |
|---|---|
| chainId, vault, owner, token | The deployment and balance identity. Check these against your trusted configuration. |
| blockNumber, blockHash, blockTimestamp | The checked snapshot. Number and Unix timestamp are decimal strings. |
| pageBalance | Matching open principal on this page, as a decimal string in raw token units. |
| pageOffset, pagePositionCount | The lifetime-history offset and the number of positions scanned on this page. |
| scannedPositions, totalPositions | Cumulative history coverage and the wallet's lifetime position count, as decimal strings. |
| complete, nextCursor | Whether history is exhausted and the cursor for the next page, or null when complete. |
The downloadable helper validates response identity, snapshot consistency and contiguous pagination. It returns a bigint balance only after the scan is complete. It uses fetch and BigInt with no package dependencies; use a modern browser or Node.js 22 or later.
import { readVaultHoldings } from "./read-vault-holdings";
const holdings = await readVaultHoldings({
apiBaseUrl: "https://www.conviction.locker",
chainId: 4663,
vault: "0x71d80F63E3Df575b64d7F3f2c7A7b4BF7443659A",
owner: "0xB5dE4Dd7e787ed59E4d060EF4a36dEefEb9eB51A",
token: "0xB6ff957E31286F748989Cbf69183b1b5e8d921A8",
signal: AbortSignal.timeout(30_000),
});
// Only returned after every page has been checked.
const holdsVaultedTokens = holdings.balance > 0n;
console.log(holdings.balance.toString(), holdings.blockNumber.toString());read-vault-holdings.ts
Validated TypeScript client with complete pagination and cancellation.
read-vault-holdings.ts
export interface VaultHoldingsOptions {
apiBaseUrl: string;
chainId: number;
vault: string;
owner: string;
token: string;
signal?: AbortSignal;
}
export interface VaultHoldings {
chainId: number;
vault: string;
owner: string;
token: string;
balance: bigint;
blockNumber: bigint;
blockHash: string;
blockTimestamp: bigint;
totalPositions: bigint;
}
const uintMax = (1n << 256n) - 1n;
function address(value: unknown): string {
if (typeof value !== "string" || !/^0x[0-9a-f]{40}$/i.test(value) || /^0x0{40}$/i.test(value)) {
throw new Error("Use a nonzero wallet, token and vault address.");
}
return value.toLowerCase();
}
function uint(value: unknown): bigint {
if (typeof value !== "string" || !/^(0|[1-9][0-9]{0,77})$/.test(value) || BigInt(value) > uintMax) {
throw new Error("Invalid integer in the vault holdings response.");
}
return BigInt(value);
}
/** Read every page or throw. Never use a partial balance for holder eligibility. */
export async function readVaultHoldings(options: VaultHoldingsOptions): Promise<VaultHoldings> {
const base = new URL(options.apiBaseUrl);
const local = ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname);
if (base.username || base.password || base.pathname !== "/" || base.search || base.hash
|| (base.protocol !== "https:" && !(base.protocol === "http:" && local))) {
throw new Error("Configure a trusted HTTPS API origin; HTTP is allowed only on loopback.");
}
if (!Number.isSafeInteger(options.chainId) || options.chainId <= 0) throw new Error("Invalid chain ID.");
const identity = {
chainId: options.chainId, vault: address(options.vault), owner: address(options.owner), token: address(options.token),
};
const signal = options.signal ?? AbortSignal.timeout(30_000);
let snapshot: Omit<VaultHoldings, "balance" | "chainId" | "vault" | "owner" | "token"> | undefined;
let offset = 0n;
let balance = 0n;
let cursor: string | null = null;
const cursors = new Set<string>();
for (;;) {
signal.throwIfAborted();
const url = new URL("/api/vault-holdings", base);
url.searchParams.set("chainId", String(identity.chainId));
url.searchParams.set("owner", identity.owner);
url.searchParams.set("token", identity.token);
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, { signal, cache: "no-store", credentials: "omit", redirect: "error" });
if (!response.ok) throw new Error(`Vault holdings unavailable (HTTP ${response.status}). Retry the entire scan.`);
const value: unknown = await response.json();
signal.throwIfAborted();
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid vault holdings response.");
const page = value as Record<string, unknown>;
if (page.chainId !== identity.chainId || address(page.vault) !== identity.vault
|| address(page.owner) !== identity.owner || address(page.token) !== identity.token) {
throw new Error("The holdings response belongs to another deployment, owner or token.");
}
if (typeof page.blockHash !== "string" || !/^0x[0-9a-f]{64}$/i.test(page.blockHash)) throw new Error("Invalid snapshot hash.");
const current = {
blockNumber: uint(page.blockNumber), blockHash: page.blockHash.toLowerCase(),
blockTimestamp: uint(page.blockTimestamp), totalPositions: uint(page.totalPositions),
};
if (snapshot && (current.blockNumber !== snapshot.blockNumber || current.blockHash !== snapshot.blockHash
|| current.blockTimestamp !== snapshot.blockTimestamp || current.totalPositions !== snapshot.totalPositions)) {
throw new Error("The holdings snapshot changed. Restart the scan.");
}
snapshot ??= current;
const count = page.pagePositionCount;
const scanned = uint(page.scannedPositions);
const remaining = snapshot.totalPositions - offset;
const expected = remaining > 100n ? 100n : remaining;
if (uint(page.pageOffset) !== offset || !Number.isSafeInteger(count) || typeof count !== "number"
|| count < 0 || count > 100 || BigInt(count) !== expected || scanned !== offset + BigInt(count)
|| scanned > snapshot.totalPositions || typeof page.complete !== "boolean"
|| page.complete !== (scanned === snapshot.totalPositions)) {
throw new Error("Incomplete or inconsistent holdings pagination. Restart the scan.");
}
const pageBalance = uint(page.pageBalance);
if (count === 0 && pageBalance !== 0n) throw new Error("An empty page cannot contain a balance.");
balance += pageBalance;
if (balance > uintMax) throw new Error("The holdings balance exceeds the token quantity range.");
if (page.complete) {
if (page.nextCursor !== null) throw new Error("Unexpected continuation after a complete scan.");
return { ...identity, ...snapshot, balance };
}
if (typeof page.nextCursor !== "string" || page.nextCursor.length < 1 || page.nextCursor.length > 2_048
|| cursors.has(page.nextCursor) || count === 0) throw new Error("Missing or repeated holdings continuation.");
cursors.add(page.nextCursor);
cursor = page.nextCursor;
offset = scanned;
}
}
Keep unavailable separate from zero
| HTTP status | How to handle it |
|---|---|
| 200 | Validate the response and continue pagination if needed. |
| 400 | Correct the chain, addresses or cursor. Unknown or duplicate query parameters are rejected. |
| 405 | Use GET. OPTIONS is available for browser preflight. |
| 414 | The request URL is too long. |
| 429 | The request limit or API instance capacity was reached. Honor Retry-After when available, back off and restart the scan later. |
| 503 | The balance could not be established. Show unavailable and retry the entire scan later. |
Production requests are limited to 60 per IP address in each fixed one-minute window per edge region. Each page counts as a request. An API instance can also return 429 when its own request budget is exhausted, before your IP reaches the edge limit. Avoid concurrent scans for the same wallet and token.
The helper throws on failed requests and does not retry automatically. Wait before restarting; honor Retry-After when available and use increasing delays with jitter for repeated failures. An edge-generated 429 may have neither JSON nor CORS headers, so a browser may report a network error instead. Neither response means zero holdings.
RPC failures, incomplete reads and reorganized snapshots must never become a zero balance or a negative holder decision. A cursor is not a permanent historical bookmark: an RPC may stop serving its block. Restart from the first request if continuation fails.
Responses use no-store and the service does not cache balances. They still describe one block, not a promise about future holdings. Recheck before an action that depends on current ownership. The helper defaults to a 30-second deadline unless you supply your own AbortSignal.
Apply your holder policy
Keep quantities in raw units. For a minimum of one token with 18 verified decimals, compare balance against 10n ** 18n. This API does not fetch decimals or metadata, and a successful zero balance does not establish that the supplied token address is an ERC-20 contract.
If your rule counts both liquid and vaulted tokens, read ERC-20 balanceOf(owner) at the returned blockNumber on the same chain and verify the returned blockHash remains canonical. Adding balances from different blocks can double-count a withdrawal or miss a deposit.
vault-holdings-api.md
Request, response, pagination and holder-policy reference.