// The unified trading account — reading the linked-wallet funding rail (DEX-2361). // // PERP-ONLY, and TWO TIERS. The split is by QUESTION, not by what happens to be // available, which is why both live here rather than in separate modules: // // CHAIN — "what is outstanding RIGHT NOW", and "will this order spend my main's // wallet". Live state that a replayed log can only approximate, so these // stay bank and registry reads even though the events are indexed. // INDEXER — "how did it get there". Every proposal, link, pull, settle and return, // append-only, in `PerpWalletLinkEvent`, `PerpMarginPull` and // `PerpMainFundingEvent`. The chain keeps no history of any of it. // // THE TWO LEDGERS ARE SPLIT BY SIDE AND MUST NOT BE SUMMED TOGETHER. One pull emits a // POOL event and a BANK event for the same wei, so adding `listPerpMarginPulls` to // `listPerpMainFundingEvents` double-counts every transfer. They carry different unique // content — the pool side names the ORDER, the bank side carries the running CLAIM — and // that is the reason to read one or the other, never both as one total. // // WHY THIS EXISTS AT ALL. True isolation on perps needs one account per isolated // position — "isolated margin" is single-market confinement, not a per-position // collateral bucket — so N isolated positions mean N wallets. That used to make every // sub-account its own treasury operation. Linking the wallets lets a child's // position-increasing order draw the shortfall from its MAIN's wallet, so one treasury // serves N buckets. // // TWO LAYERS, AND THEY ARE NOT THE SAME QUESTION. // // 1. The registry (`LinkedWalletRegistry`) is the CONSENT graph — who is linked to // whom. Being linked grants no authority over funds by itself. // 2. The bank (`MarginBank`) is the MONEY layer, and it is what has to be armed: // until `getLinkedWalletRegistry()` is non-zero the rail is dormant and no child // can draw on any main no matter what the registry says. // // So "are we linked" and "will this order spend my main's wallet" are different reads, // and {@link quotePerpFundingPayer} answers the second one directly rather than making // a caller compose the first with an arming check. import type { Address, PublicClient } from "viem"; import { graphql } from "../gql/gql.js"; import * as IndexerRead from "../indexerRead.js"; import * as ReadsAbi from "../readsAbi.js"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; /** * Whether a child's next position-increasing order would draw on a main's wallet, * and whose. * * A discriminated union rather than a bare address, because zero from * `quoteFundingPayer` collapses three genuinely different situations that a UI must * not render identically: the rail is dormant, this wallet is unlinked, or this * wallet IS a main. Narrow on `funded` first. * * @category perpetual markets */ export type PerpFundingPayer = | { /** A main would be debited for whatever this account's own wallet cannot cover. */ funded: true; /** * The main whose wallet would be debited. This is the live resolution, not the * snapshot a past pull recorded — see {@link PerpMainFunding.payer} for that, * and note the two can differ after an unlink and re-link. */ payer: Address; } | { /** * No main would be debited. `reason` says which of the three cases applies; * only `unlinked` is something the user can change by linking. */ funded: false; /** * - `dormant` — the bank holds no registry address, so the feature is off for * everyone on this deployment. Linking would not help. * - `unlinked` — the rail is armed but this wallet has no main. * - `isMain` — this wallet resolves to itself. Mains fund children, not the * reverse; funding flows main->child only. */ reason: "dormant" | "unlinked" | "isMain"; }; /** * The outstanding claim against a child, and who it is owed to. * * What a main funds can be BORROWED, NEVER WITHDRAWN: `withdraw` pays the child at * most `balance - principal`, so a compromised child key can trade the money and lose * it but cannot take it out. * * @category perpetual markets */ export interface PerpMainFunding { /** * Principal a main has funded and not yet recovered, in collateral units. Zero * means nothing is outstanding. * * It is NOT a segregated bucket. The child's own money and its main's are one * fungible balance, and the claim is clamped to `min(principal, balance)` at flat * moments — so the child's own contribution is the JUNIOR tranche and a loss eats * it first. A child that genuinely lost the money does not owe it forever. */ principal: bigint; /** * The payer recorded AT FUNDING TIME, or zero when nothing is outstanding. * * Snapshotted deliberately: both routes home settle against this address, so an * unlink or re-link between the pull and the repayment cannot misroute the money to * whoever happens to be linked later. When this disagrees with the LIVE resolution * ({@link PerpFundingPayer}), THIS is who gets repaid. */ payer: Address; /** * How much of `principal` the child could withdraw: always zero while a claim * stands, and present as a field only so a caller does not have to re-derive the * rule from prose. Included because "why can I not withdraw my balance" is the * commonest question this surface has to answer. */ readonly withdrawableFromPrincipal: 0n; } /** * A link group, with the maturity the raw graph does not carry. * * `maturesAt` matters for a reason worth stating: ADL netting is gated on maturity so * that a link armed in reaction to an impending auto-deleveraging cannot buy netting * credit. The FUNDING rail deliberately reads the raw graph instead, because the main * proposed the link and owns the allowance, and there is no analogous surprise. So a * link can be fundable and not yet mature — do not use `maturesAt` to decide whether * a pull will happen. * * @category perpetual markets */ export interface PerpWalletLinkage { /** The group's main. Zero when the wallet is unlinked. */ main: Address; /** The main plus every child. Empty when the wallet is unlinked. */ members: readonly Address[]; /** Unix seconds the link was formed, or 0. */ linkedAt: bigint; /** Unix seconds the link becomes mature FOR ADL NETTING, or 0. Not a funding gate. */ maturesAt: bigint; /** Convenience: whether this wallet is a child (has a main that is not itself). */ isChild: boolean; /** Convenience: whether this wallet is a main with at least one child. */ isMain: boolean; } /** * The registry the bank resolves links through, or `null` while the rail is dormant. * * Chain tier. Read this from the BANK rather than from a deployment manifest: the * bank is what decides which registry is authoritative, and a registry nobody has * armed is inert. `null` here means every other read in this module will report * "not funded" regardless of what any registry contains. */ export async function getPerpLinkedWalletRegistry(marginBank: Address, client: PublicClient): Promise
{ const registry = await client.readContract({ address: marginBank, abi: ReadsAbi.marginBankReadAbi, functionName: "getLinkedWalletRegistry", }); return registry === ZERO_ADDRESS ? null : registry; } /** * Will this account's next position-increasing order spend a main's wallet, and whose? * * Chain tier. Two reads, because the contract's single zero cannot distinguish * "dormant" from "unlinked" and a UI has to: one of those is the user's to fix by * linking, the other is not. * * The arming check is done FIRST and short-circuits, so a deployment with the rail off * costs one read and never reports a misleading `unlinked`. */ export async function quotePerpFundingPayer( marginBank: Address, account: Address, client: PublicClient, ): Promise { const registry = await getPerpLinkedWalletRegistry(marginBank, client); if (registry === null) return { funded: false, reason: "dormant" }; const payer = await client.readContract({ address: marginBank, abi: ReadsAbi.marginBankReadAbi, functionName: "quoteFundingPayer", args: [account], }); if (payer !== ZERO_ADDRESS) return { funded: true, payer }; // The bank returned zero with the rail armed, so it is the graph that declines. Ask // the registry which way: a wallet that resolves to ITSELF is a main (funding flows // main->child only), and anything else is genuinely unlinked. const main = await client.readContract({ address: registry, abi: ReadsAbi.linkedWalletRegistryReadAbi, functionName: "mainOf", args: [account], }); return { funded: false, reason: main === account ? "isMain" : "unlinked" }; } /** * What a main has funded into a child and not yet recovered. * * Chain tier, two reads batched. Safe to call on any account — a wallet that has * never been funded reports zero principal and a zero payer rather than reverting. */ export async function getPerpMainFunding( marginBank: Address, account: Address, client: PublicClient, ): Promise { const bank = { address: marginBank, abi: ReadsAbi.marginBankReadAbi } as const; const [principal, payer] = await Promise.all([ client.readContract({ ...bank, functionName: "getMainFundedPrincipal", args: [account] }), client.readContract({ ...bank, functionName: "getMainFundingPayer", args: [account] }), ]); return { principal, payer, withdrawableFromPrincipal: 0n }; } /** * What a wallet could actually contribute to a pull right now — `min(balance, * allowance)`, in collateral units. * * Chain tier. This is why a child holding no ERC20 approval contributes zero rather * than reverting the order: the rail SIZES its own contribution with this figure * instead of attempting a transfer and failing. * * Read it two ways, and they answer different questions. On the MAIN it is the ceiling * on what its children can collectively draw — and the number a main reduces to revoke * the rail without unlinking, since consent is the allowance. On the CHILD it is how * much of its own money it will burn before reaching its main's. */ export async function getPerpWalletPullCapacity( marginBank: Address, wallet: Address, client: PublicClient, ): Promise { return client.readContract({ address: marginBank, abi: ReadsAbi.marginBankReadAbi, functionName: "quoteWalletCapacity", args: [wallet], }); } /** * A wallet's link group and its ADL-netting maturity. * * Chain tier, one read. Takes the REGISTRY address, not the bank — resolve it with * {@link getPerpLinkedWalletRegistry} so a dormant deployment is visible as such * rather than as an empty group. * * `isChild` / `isMain` are derived here because the raw encoding is a trap: a main * resolves to ITSELF in `mainOf`, so the natural test `main !== zero` is true for * mains and children alike. */ export async function getPerpWalletLinkage( registry: Address, wallet: Address, client: PublicClient, ): Promise { const linkage = await client.readContract({ address: registry, abi: ReadsAbi.linkedWalletRegistryReadAbi, functionName: "linkageOf", args: [wallet], }); const main = linkage.main; const linked = main !== ZERO_ADDRESS; return { main, members: linkage.members, linkedAt: BigInt(linkage.linkedAt), maturesAt: BigInt(linkage.maturesAt), isChild: linked && main !== wallet, isMain: linked && main === wallet && linkage.members.length > 1, }; } /** * Every child of a main, excluding the main itself. * * Chain tier. Bounded by the registry's `maxChildren`, so this is the list of isolated * buckets one treasury currently serves. */ export async function listPerpLinkedChildren( registry: Address, main: Address, client: PublicClient, ): Promise { return client.readContract({ address: registry, abi: ReadsAbi.linkedWalletRegistryReadAbi, functionName: "childrenOf", args: [main], }); } /** * How many children one main may hold — the cap on isolated buckets per treasury. * * Chain tier. Worth reading before offering to link another wallet, since the cap is * owner-tunable and a client that hardcodes it will offer a link the registry refuses. */ export async function getPerpMaxLinkedChildren(registry: Address, client: PublicClient): Promise { return client.readContract({ address: registry, abi: ReadsAbi.linkedWalletRegistryReadAbi, functionName: "maxChildren", }); } // ------------------------------------------------------------ the indexed history // // INDEXER tier. Three append-only streams, each answering a question no chain read can: // the CONSENT graph over time, the PULLS that funded placements, and the running CLAIM // against a child. See the module header for why the last two must never be summed. /** * One state change in the linked-wallet consent graph (mirror of the indexer * `PerpWalletLinkEvent` entity). * * Four kinds, and one of them is the only record that exists: * * - `Proposed` — a main offered a link. **The registry has no getter for a pending * proposal**, so this row is the ONLY way a child learns one was made. Without it a * pending offer is invisible to everything off-chain. * - `ProposalCancelled` — the main withdrew the offer before it was accepted. A client * showing an inbox must apply this, or it keeps offering a link that no longer stands. * - `Linked` — the child accepted; the group exists from here. * - `Unlinked` — either side tore the link down. Consent for the funding rail IS the link * plus the allowance, with no separate opt-out flag, so this is the revocation event. * * **Gotcha.** This is the CONSENT graph, not the money layer. A `Linked` row grants no * authority over funds by itself — the rail is dormant until the bank's * `getLinkedWalletRegistry()` is non-zero and the main has given an allowance. Ask * {@link SomniaMarketsClient.quotePerpFundingPayer | client.quotePerpFundingPayer} whether an * order would actually spend a main's wallet; do not infer it from a link. * * @category perpetual markets */ export type PerpWalletLinkEvent = { /** Row id (`${txHash}_${logIndex}`). */ id: string; /** `Proposed` | `ProposalCancelled` | `Linked` | `Unlinked`. */ kind: string; /** The main side of the pair (lowercased) — present on every kind. */ main: string; /** The child side of the pair (lowercased) — present on every kind. */ child: string; /** Timestamp (unix seconds) of the change. */ timestamp: string; /** Block the change landed in. */ blockNumber: string; /** Position within the block — needed to order two changes to the same pair in one block. */ logIndex: number; /** Tx hash the change landed in. */ txHash: string; }; /** * One leg of margin pulled to fund a placement (mirror of the indexer `PerpMarginPull` * entity). **Pool side.** * * `source` names which wallet paid, and one placement can produce BOTH legs, in this * order: * * - `OwnWallet` — the owner's own wallet funded it, sized by `min(balance, allowance)`. * - `Main` — the residual reached the owner's linked MAIN, whose address is `payer`. * * So a child with no approval of its own contributes zero and the whole requirement * arrives as one `Main` leg, while a partly-funded child produces two rows for one * order. `amount` is what THIS leg pulled, never the order's total requirement — sum the * legs sharing an `orderId` for that. * * **Gotcha.** Do not add these to {@link PerpMainFundingEvent} rows: the same wei appears * on both sides. See the module header. * * @category perpetual markets */ export type PerpMarginPull = { /** Row id (`${txHash}_${logIndex}`). */ id: string; /** The account credited, and whose position the order was for (lowercased). */ account: string; /** The PerpPool that pulled (lowercased). */ pool: string; /** The order whose placement caused the pull — decimal string, matches `Order.orderId`. */ orderId: string; /** `OwnWallet` | `Main` — which wallet paid this leg. */ source: string; /** Wei pulled by THIS leg, raw collateral units — not the order's total requirement. */ amount: string; /** The main whose wallet was debited (`Main` only; null for `OwnWallet`), lowercased. */ payer: string | null; /** Timestamp (unix seconds) of the pull. */ timestamp: string; /** Block the pull landed in. */ blockNumber: string; /** Tx hash the pull landed in. */ txHash: string; }; /** * One movement of a main's claim against a child (mirror of the indexer * `PerpMainFundingEvent` entity). **Bank side**, and the running principal. * * - `Funded` — a main's wallet was debited for the child. `amount` moved; `payer` is the * main; `outstandingPrincipal` is the claim after. * - `Settled` — the child's own losses discharged part of the claim at a flat moment. * **NO money moved**, so `amount` is null and `previousPrincipal` → `outstandingPrincipal` * is the whole content. `payer` is null: a settle has no counterparty. * - `Returned` — principal went home, by either signed route (`repayFunding` by the child * or `recallFromChild` by the payer). Both settle against the SNAPSHOTTED payer, which * is why `payer` here can differ from the child's current main. * * **Gotchas.** * * - **`amount` is null on `Settled`, and that is not missing data** — it is the point. * Treating null as zero is right for a cash total and wrong for a claim total; the * claim still fell, which `outstandingPrincipal` records. * - Do not add these to {@link PerpMarginPull} rows. Same wei, two sides. See the module * header. * - The live claim is {@link SomniaMarketsClient.getPerpMainFunding | client.getPerpMainFunding} — * a bank read. This is how it got * there, not what it is now. * * @category perpetual markets */ export type PerpMainFundingEvent = { /** Row id (`${txHash}_${logIndex}`). */ id: string; /** The child account the claim is against (lowercased). */ account: string; /** `Funded` | `Settled` | `Returned`. */ kind: string; /** The main on the other side (lowercased). Null on `Settled`, which has no counterparty. */ payer: string | null; /** Wei that actually moved, raw collateral units. **Null on `Settled`** — see the type note. */ amount: string | null; /** The claim BEFORE this event (`Settled` only). */ previousPrincipal: string | null; /** The claim after this event. */ outstandingPrincipal: string | null; /** Timestamp (unix seconds) of the movement. */ timestamp: string; /** Block the movement landed in. */ blockNumber: string; /** Tx hash the movement landed in. */ txHash: string; }; // prettier-ignore const PerpWalletLinkEventsQuery = graphql(` query PerpWalletLinkEvents($where: PerpWalletLinkEvent_bool_exp!, $limit: Int, $offset: Int) { PerpWalletLinkEvent(where: $where, order_by: [{blockNumber: desc}, {logIndex: desc}], limit: $limit, offset: $offset) { id kind main child timestamp blockNumber logIndex txHash } } `); // prettier-ignore const PerpMarginPullsQuery = graphql(` query PerpMarginPulls($where: PerpMarginPull_bool_exp!, $limit: Int, $offset: Int) { PerpMarginPull(where: $where, order_by: [{blockNumber: desc}, {id: desc}], limit: $limit, offset: $offset) { id account pool orderId source amount payer timestamp blockNumber txHash } } `); // prettier-ignore const PerpMainFundingEventsQuery = graphql(` query PerpMainFundingEvents($where: PerpMainFundingEvent_bool_exp!, $limit: Int, $offset: Int) { PerpMainFundingEvent(where: $where, order_by: [{blockNumber: desc}, {id: desc}], limit: $limit, offset: $offset) { id account kind payer amount previousPrincipal outstandingPrincipal timestamp blockNumber txHash } } `); /** * The linked-wallet consent graph over time, newest first. * * Indexer tier. Filter by `main`, by `child`, or by `kind`; a wallet that is both a main * and a child appears under each with the matching filter. Omit them all for the whole * graph. * * **This is the only way to see a PENDING proposal.** The registry exposes no getter for * one, so this log is the sole off-chain evidence that an offer exists at all. * * **What it yields is a CANDIDATE list, not a set of accepts that will succeed.** * `acceptLink` reads live storage and applies five guards; this log can only settle * three of them, and every one is a CURRENT-STATE question rather than an * ever-happened one: * * 1. **Supersession, per DIRECTED pair.** An offer stands only while the pair's NEWEST * row is the `Proposed` itself — `ProposalCancelled`, `Linked` or `Unlinked` after it * retires it. Mind the direction on the last: `unlink` deletes `key(main, child)` * **and** `key(child, main)`, so an `Unlinked` naming this wallet as MAIN can retire * an offer it holds as CHILD. Testing "has any non-`Proposed` row ever appeared" * instead hides RE-proposals, because `unlink` returns a wallet to where it started * and offers that were dead become live again. * 2. **The child must be free right now** (`AlreadyLinked`). Accepting one main leaves * the losing mains' proposals in storage, dead only while that link stands. * 3. **The main must not itself be a child right now** (`CallerIsChild`). * * Guards 2 and 3 are live state, so read them with {@link getPerpWalletLinkage} * (`isChild` / `isMain`) rather than replaying them off a page of log rows that may be * truncated. The remaining two — `CallerIsMain` and `MaxChildrenReached` — are not * answerable from this log at all: `MaxChildrenUpdated` is deliberately not subscribed. * Confirm a candidate on chain before presenting it as an accept that will work. * * Ordered by `(blockNumber, logIndex)` descending, which is true chain order. Not * `timestamp`: a propose and its accept can land in one block, and separate blocks can * share a timestamp while `logIndex` restarts in each — so a `(timestamp, logIndex)` * sort interleaves rows from different blocks. `indexer/schema.graphql` states this * requirement on the entity, and it is why `logIndex` is a column and not just part of * the id. * * @example A child's inbox — candidate offers, correctly superseded * ```ts * // Both directions: an `unlink` naming me as MAIN also retires the offer the same * // pair holds the other way round. * const [inbound, outbound] = await Promise.all([ * client.listPerpWalletLinkEvents({ child: me, limit: 200 }), * client.listPerpWalletLinkEvents({ main: me, limit: 200 }), * ]); * const rows = [...inbound, ...outbound]; * // Chain order is the PAIR (blockNumber, logIndex) — compared as a pair, because * // logIndex restarts in every block and so cannot be packed against an unbounded one. * const isAfter = (a: PerpWalletLinkEvent, b: PerpWalletLinkEvent) => * BigInt(a.blockNumber) === BigInt(b.blockNumber) * ? a.logIndex > b.logIndex * : BigInt(a.blockNumber) > BigInt(b.blockNumber); * const onPair = (r: PerpWalletLinkEvent, main: string) => * (r.main === main && r.child === me) || (r.main === me && r.child === main); * * // Guard 1 only. Still candidates until guards 2 and 3 are read from the chain. * const candidates = inbound * .filter((p) => p.kind === "Proposed") * .filter((p) => !rows.some((r) => onPair(r, p.main) && isAfter(r, p))); * ``` */ export async function listPerpWalletLinkEvents( opts: { main?: string; child?: string; kind?: string; limit?: number; offset?: number } = {}, indexerUrl: string, ): Promise { const where: Record = {}; if (opts.main != null) where.main = { _eq: opts.main.toLowerCase() }; if (opts.child != null) where.child = { _eq: opts.child.toLowerCase() }; if (opts.kind != null) where.kind = { _eq: opts.kind }; const data = await IndexerRead.gqlRequest( PerpWalletLinkEventsQuery, { where, limit: opts.limit ?? 50, offset: opts.offset ?? 0 }, indexerUrl, ); return data.PerpWalletLinkEvent; } /** * Margin pulled to fund placements, newest first — the POOL side of the rail. * * Indexer tier. Filter by `account`, `pool`, `orderId` or `source`. Filtering by * `source: "Main"` is how to see only what a linked main actually paid for, as opposed to * what the owner's own wallet covered. * * **Do not sum these with {@link listPerpMainFundingEvents}** — one pull emits a row on * each side for the same wei. Read this side when the question involves an ORDER, since * it is the side that names one. * * Ordered by `blockNumber` descending, with `id` as a tiebreaker. The tiebreaker makes * `offset` pagination STABLE — no row repeats or disappears between pages — but it is * not intra-block chain order: this entity carries no `logIndex` column, and its `id` is * `txHash_logIndex`, so rows inside one block come back in a deterministic but arbitrary * sequence. Ordering by `timestamp` would be worse still, since separate blocks can * share one. Read `blockNumber` when the relative order of two rows in the same block * matters; a `logIndex` column here needs a schema change and therefore a reindex. * * @example What a main paid for one order * ```ts * const legs = await client.listPerpMarginPulls({ orderId, source: "Main" }); * const fromMain = legs.reduce((t, l) => t + BigInt(l.amount), 0n); * ``` */ export async function listPerpMarginPulls( opts: { account?: string; pool?: string; orderId?: string; source?: string; limit?: number; offset?: number } = {}, indexerUrl: string, ): Promise { const where: Record = {}; if (opts.account != null) where.account = { _eq: opts.account.toLowerCase() }; if (opts.pool != null) where.pool = { _eq: opts.pool.toLowerCase() }; // An order id is a decimal string, not an address — lowercasing it would be harmless but // meaningless, and hiding that it is not an address is worse than leaving it exact. if (opts.orderId != null) where.orderId = { _eq: opts.orderId }; if (opts.source != null) where.source = { _eq: opts.source }; const data = await IndexerRead.gqlRequest( PerpMarginPullsQuery, { where, limit: opts.limit ?? 50, offset: opts.offset ?? 0 }, indexerUrl, ); return data.PerpMarginPull; } /** * A main's claim against a child over time, newest first — the BANK side of the rail, * carrying the running principal. * * Indexer tier. Filter by `account` (the child the claim is against), by `payer` (the * main), or by `kind`. The live claim is * {@link SomniaMarketsClient.getPerpMainFunding | client.getPerpMainFunding}; this is the * ledger behind it. * * **Do not sum these with {@link listPerpMarginPulls}** — same wei, two sides. Read this * side when the question is about the CLAIM, since it is the side that carries it. * * Ordered by `blockNumber` descending, with `id` as a tiebreaker. The tiebreaker makes * `offset` pagination STABLE — no row repeats or disappears between pages — but it is * not intra-block chain order: this entity carries no `logIndex` column, and its `id` is * `txHash_logIndex`, so rows inside one block come back in a deterministic but arbitrary * sequence. Ordering by `timestamp` would be worse still, since separate blocks can * share one. Read `blockNumber` when the relative order of two rows in the same block * matters; a `logIndex` column here needs a schema change and therefore a reindex. * * @example How a child's claim moved, and by which route * ```ts * const ledger = await client.listPerpMainFundingEvents({ account: child }); * // `Settled` rows moved no cash — amount is null — but still reduced the claim. * const timeline = ledger.map((r) => ({ kind: r.kind, claim: r.outstandingPrincipal })); * ``` */ export async function listPerpMainFundingEvents( opts: { account?: string; payer?: string; kind?: string; limit?: number; offset?: number } = {}, indexerUrl: string, ): Promise { const where: Record = {}; if (opts.account != null) where.account = { _eq: opts.account.toLowerCase() }; if (opts.payer != null) where.payer = { _eq: opts.payer.toLowerCase() }; if (opts.kind != null) where.kind = { _eq: opts.kind }; const data = await IndexerRead.gqlRequest( PerpMainFundingEventsQuery, { where, limit: opts.limit ?? 50, offset: opts.offset ?? 0 }, indexerUrl, ); return data.PerpMainFundingEvent; }