Conviction Documentation

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 balanceIncluded
Flexible lock or No early exit lockYes, while the principal remains in the vault.
Matured position awaiting releaseYes. Maturity alone does not move the tokens.
Lock funded by another walletYes, for the beneficiary who owns the position.
Released or exited positionNo. Historical amounts are not current holdings.
Liquid wallet balance or unclaimed rewardsNo. 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.

Request CONV held in the production vault
curl --get 'https://www.conviction.locker/api/vault-holdings' \
  --data-urlencode 'chainId=4663' \
  --data-urlencode 'owner=0xB5dE4Dd7e787ed59E4d060EF4a36dEefEb9eB51A' \
  --data-urlencode 'token=0xB6ff957E31286F748989Cbf69183b1b5e8d921A8'
Query parameterValue
chainIdRequired decimal chain ID. It must match the API deployment.
ownerRequired nonzero wallet address whose positions should count.
tokenRequired nonzero ERC-20 address. Any token deposited in the configured vault can be checked.
cursorOptional 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 fieldsMeaning
chainId, vault, owner, tokenThe deployment and balance identity. Check these against your trusted configuration.
blockNumber, blockHash, blockTimestampThe checked snapshot. Number and Unix timestamp are decimal strings.
pageBalanceMatching open principal on this page, as a decimal string in raw token units.
pageOffset, pagePositionCountThe lifetime-history offset and the number of positions scanned on this page.
scannedPositions, totalPositionsCumulative history coverage and the wallet's lifetime position count, as decimal strings.
complete, nextCursorWhether 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.

Read every page before deciding eligibility
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.

Download guide
read-vault-holdings.tstypescript
typescript
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 statusHow to handle it
200Validate the response and continue pagination if needed.
400Correct the chain, addresses or cursor. Unknown or duplicate query parameters are rejected.
405Use GET. OPTIONS is available for browser preflight.
414The request URL is too long.
429The request limit or API instance capacity was reached. Honor Retry-After when available, back off and restart the scan later.
503The 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.

Download guide