Skip to content

Monitor LOCs and vault state

Outcome: a dashboard or alerting service that lists every LOC an account has created or received, shows each one’s current risk, explains what happened to it transaction by transaction, and reconciles the Collateral Vault balances behind it.

You need: a configured SDK. Monitoring is read-only, so it needs no wallet: a service supplies the account addresses it watches rather than calling getSignerAddress().

Use the full record for dashboards and history, and a fresh read of the live position when you prepare an operation. Token metadata, prices, pair settings, and caller permissions are separate reads, so keep them apart from those facts.

Every LOC an account has touched

sdk.loc.getLetterOfCredits returns the full record of each LOC, LetterOfCredit, including LOCs that have been redeemed or canceled. Each record keeps its origin, its redemption and liquidation facts, how it resolved, and its remaining indexed state. The SDK profile selects the chain and the LetterOfCredit contract, and each returned reference carries that full identity.

const creator = await sdk.getSignerAddress();
const filters = {
creators: [creator],
creationKind: 'dynamic' as const,
limit: 50,
};
let page = await sdk.loc.getLetterOfCredits(filters);
const locs = [...page.items];
while (page.nextCursor !== null) {
page = await sdk.loc.getLetterOfCredits({
...filters,
cursor: page.nextCursor,
});
locs.push(...page.items);
}
// Includes resolved LOCs. Every continuation preserves the first index
// boundary.
console.log(locs.map(loc => [loc.reference, loc.lifecycle.resolution]));

Follow nextCursor until it’s null. A cursor is bound to its filters, namespace, subgraph deployment, and block hash, so pass the original filters when you continue. The whole traversal reads from the same point in what the subgraph has indexed, even if indexing moves on meanwhile. If one page fails, treat the whole collection as failed: the LOCs on that page still exist. Start each new polling cycle without a cursor to read from the latest indexed point. Set outstanding: true to list only LOCs with a live position (including expired ones), or outstanding: false for resolved LOCs. Leave it out to get both.

An unknown id returns undefined from the single-LOC read. A subgraph with an incompatible schema, incomplete lifecycle evidence, or a change in indexed point mid-read throws SubgraphCompatibilityError, which is different from an empty portfolio. These reads need the current subgraph schema, fully indexed, and they use the endpoint the profile names as it is.

Report an incompatible subgraph separately from a LOC that doesn’t exist, and show the failure to the host rather than an empty result:

import { SubgraphCompatibilityError } from '@anvil/sdk/core';
import type { AnvilSDK } from '@anvil/sdk/core';
export async function readLOCWithCompatibility(sdk: AnvilSDK, id: bigint) {
try {
const loc = await sdk.loc.getLetterOfCredit({ id });
if (loc === undefined) {
console.log('No LOC with this ID exists at the indexed boundary.');
}
return loc;
} catch (error) {
if (error instanceof SubgraphCompatibilityError) {
// Report incompatibility separately from an absent LOC.
console.error(
'The index cannot serve this LOC model:',
error.reason
);
}
throw error;
}
}

The live position

sdk.loc.getOutstandingLetterOfCredit({ id }) reads the live position, OutstandingLetterOfCredit, straight from the contract. It holds the remaining credited amount, the current expiration, and the backing: either a vault reservation with its collateral, or credited tokens after conversion. Origin, token metadata, and pair settings are separate reads. An expired LOC that nobody has canceled yet is still outstanding. A resolved LOC and an unknown id both return undefined, because the contract removes a LOC’s live position when it resolves.

// Every outstanding dynamic LOC created by the connected account,
// including expired LOCs.
const creator = await sdk.getSignerAddress();
const filter = { creators: [creator], creationKind: 'dynamic' as const };
// Keep execution controls outside the filters. A route or component can
// call controller.abort(reason) to stop the in-flight page and any retry
// backoff immediately; the SDK also bounds each page with its own
// deadline.
const controller = new AbortController();
let page = await sdk.loc.getOutstandingLetterOfCredits(
{ ...filter, limit: 50 },
{ signal: controller.signal }
);
const locs = [...page.items];
while (page.nextCursor !== null) {
page = await sdk.loc.getOutstandingLetterOfCredits(
{
...filter,
limit: 50,
cursor: page.nextCursor,
},
{ signal: controller.signal }
);
locs.push(...page.items);
}
// Pages can be short: LOCs the subgraph still lists but the chain has
// purged are dropped.
console.log(`${locs.length} outstanding dynamic LOCs`);

The outstanding listing uses the subgraph to find candidate ids, then confirms each one onchain. A page can come back short when the contract has already removed some candidates, so only a null cursor ends the traversal. The creationKind filter describes how the LOC was created, so a converted Dynamic LOC still matches dynamic; read backing.kind to tell reserved backing from converted. This listing returns live positions only, without history or creation fields.

Before submitting an operation, read the live position again and run the operation’s SDK validator. A dashboard status from earlier can’t tell you whether a new transaction will succeed.

Collateral and risk

Collateral protects the Beneficiary if the Creator doesn’t otherwise supply the credited token. The contract records both the amount reserved and the amount currently claimable after collateral fees. Collateral factors and LOC liquidation explain the model; this section covers the parts the SDK’s math depends on.

For a Dynamic LOC, the protocol compares the market value of the collateral, in units of the credited token, with the LOC’s credited-token face value. The collateral factor is the credited value divided by the collateral value, in basis points, where 10,000 is 100%. A credited value of 80 against collateral worth 100 is an 80% collateral factor, or 8,000 basis points. A lower factor means more collateral behind each unit of credited value.

The contract first converts the collateral amount into credited-token units, then derives the factor, rounding each division down. If the collateral converts to zero credited-token units, or the factor would exceed the uint16 range, the contract reports the maximum factor of 65,535 basis points. The SDK’s contract-parity estimates follow the same order and rounding.

Each enabled collateral/credited-token pair has Governance-set limits:

  • the maximum collateral factor allowed when creating a LOC or removing collateral;
  • the liquidation collateral factor, at which anyone can convert the LOC;
  • the liquidator incentive paid on an unhealthy or insolvent liquidation; and
  • the redemption buffer charged when a healthy LOC’s collateral is liquidated to serve a redemption.

A LOC is unhealthy when its current collateral factor is greater than or equal to its liquidation collateral factor.

Every open Dynamic LOC uses its pair’s current settings. Health and fee math read the pair’s configuration at the time of the read, so an Anvil Governance change to a pair applies to open LOCs immediately. The per-LOC collateral factor and incentive fields in contract storage are left over from earlier versions: V3 writes zero to them and never reads them, so read the current pair configuration instead.

The SDK’s LOC models leave those legacy fields out. LetterOfCredit.creationContext records the external configuration seen at creation, when that evidence exists; it’s history, not an input to current risk. OutstandingLetterOfCredit carries no pair settings. Read the current configuration with sdk.loc.getCollateralFactor, read a sourced pair price with sdk.pricing.getTokenPairPrice, and pass both, with an explicit clock, to assessLetterOfCreditRisk. Static and converted backing carry no liquidation risk, and missing inputs for a Dynamic LOC throw an error rather than reporting zero risk.

Unhealthy and insolvent are different states. An unhealthy LOC has reached its liquidation threshold, so anyone can convert its collateral, though the collateral is usually still worth more than the credited value. An insolvent LOC’s collateral is worth less than its face value plus fees, so a full redemption or conversion delivers less than the face value: the liquidator and protocol fees are still paid in full, and the Beneficiary absorbs the shortfall.

Status, risk, and what a caller can do

deriveLetterOfCreditStatus(loc, now) works from the full record and an explicit Unix-second clock. A LOC counts as expired from the expiration second itself. The status it returns is a summary for display, and some facts overlap: a LOC can be converted, partially redeemed, and expired at once. A shortfall observed at full conversion stays in the record as insolvency even after redemption or cancellation. Read lifecycle for the complete account.

Current risk works from the live position, a sourced pair price, and the pair’s current configuration:

const loc = await sdk.loc.getOutstandingLetterOfCredit({ id });
if (!loc) return undefined;
const now = BigInt(Math.floor(Date.now() / 1000));
if (loc.creationKind !== 'dynamic' || loc.backing.kind !== 'reserved') {
return assessLetterOfCreditRisk(loc, null, null, now);
}
const [price, factor] = await Promise.all([
sdk.pricing.getTokenPairPrice({
inputToken: loc.backing.collateral.tokenAddress,
outputToken: loc.remainingCredited.tokenAddress,
}),
sdk.loc.getCollateralFactor({
collateralToken: loc.backing.collateral.tokenAddress,
creditedToken: loc.remainingCredited.tokenAddress,
}),
]);
// Missing or unusable dynamic inputs throw; never substitute a healthy
// result.
return assessLetterOfCreditRisk(loc, price ?? null, factor ?? null, now);

assessLetterOfCreditRisk follows the contract’s integer arithmetic for health and liquidation coverage. Static and converted backing have no liquidation risk, so those assessments need no prices or pair configuration. For reserved Dynamic backing, missing or unusable inputs throw: show risk as unavailable, offer a retry, and keep the error an error rather than a healthy result. A projected shortfall is a current estimate, separate from a conversion loss already recorded in lifecycle.

deriveLetterOfCreditDirectCapabilities(loc, status, risk, caller) answers which actions the state allows for the declared msg.sender calling the LetterOfCredit contract directly. Pass null for an absent caller, and every capability is false. Pass null when risk is unavailable, and conversion stays disabled. The risk assessment must be for the same LOC and clock. Capabilities cover direct calls only, without delegation, signatures, liquidator routing, or Beneficiary-contract roles, and each operation still runs its own validation.

Resolve display metadata separately with sdk.tokens.getToken({ address }). If that read fails, keep showing the address and base-unit amount, with a retry. A token that’s disabled for new deposits, or missing from today’s creation list, can still back an existing LOC.

Transaction history

The SDK returns each LOC’s complete history, paginated. Each entry covers one transaction: the state before and after, what changed, and the exact events behind each change.

const id = 42n;
let page = await sdk.loc.getLetterOfCreditHistory({ id, limit: 25 });
const entries = [...page.items];
while (page.nextCursor !== null) {
page = await sdk.loc.getLetterOfCreditHistory({
id,
limit: 25,
cursor: page.nextCursor,
});
entries.push(...page.items);
}
for (const entry of entries) {
console.log(entry.transaction.hash, entry.before, entry.after);
for (const change of entry.changes) {
console.log(change.type, change);
for (const event of change.events) {
console.log(event.sourceEvent, event.transactionLogIndex);
}
}
}

A full Dynamic redemption can include conversion and redemption events within a single redemption change, rather than as a separate conversion call. A partial Dynamic redemption likewise combines liquidation and redemption events. Each event keeps its source event name and log position. The transaction sender records who submitted it, which may differ from who acted: actor addresses come from event data or from an authorization rule the protocol enforces. Older facts the subgraph never captured stay null rather than guessed.

History cursors keep the deployment and block they started from, and each page holds complete transaction entries. Use changes to present history and each change’s events as evidence. A portfolio read leaves this history out, so it stays fast.

Conversion events and calls

LOCConverted records a full collateral conversion, which may or may not come from a call to convertLOC. A full Dynamic redemption uses the same liquidation engine: it emits LOCConverted, then LOCRedeemed, and removes the LOC in the same transaction. A standalone convertLOC emits LOCConverted alone and leaves the converted LOC open. Indexers and metrics that care who acted should tell those two sequences apart, rather than counting every LOCConverted as a keeper call. A partial Dynamic redemption emits LOCPartiallyLiquidated followed by LOCRedeemed.

Vault balances and reservations

Collateral reservations belong to the Collateral Vault, and any reserving contract can make one: a reservation may back a LOC or another product. The SDK reports reservations as the vault records them, without labeling each one as a LOC or a staking position.

const filters = {
accountAddress,
collateralContractAddress,
active: true,
};
let page = await sdk.vault.getCollateralReservations(filters);
const reservations: CollateralReservation[] = [...page.items];
while (page.nextCursor !== null) {
page = await sdk.vault.getCollateralReservations({
...filters,
cursor: page.nextCursor,
});
reservations.push(...page.items);
}
// Group by chain, vault, account, token and reserving contract.
// A reserving contract's address does not by itself identify its
// product.
const allocations = deriveCollateralAllocations(reservations);
console.log(page.indexedAt, allocations);

getCollateralReservation({ reference }) reads one reservation directly from the contract by its full chain, vault, and id identity. Indexed listings keep inactive reservations with a current amount of zero, while the direct read returns undefined once the contract removes one. deriveCollateralAllocations sums active reservations by chain, vault, account, token, and reserving contract, and rejects duplicate reservation identities.

When a form only needs available funds, read one token’s balance:

const balance = await sdk.vault.getCollateralBalance({
tokenAddress: WETH,
});
// `reserved` is locked by reserving contracts; only `available` can be
// withdrawn
// or back a new LOC. Withdrawals pay the vault fee, so the wallet
// receives:
const feeBasisPoints = await sdk.vault.getWithdrawalFeeBasisPoints();
const fee = (balance.available * BigInt(feeBasisPoints)) / 10_000n;
const received = balance.available - fee;

available can fund a new reservation or a withdrawal. reserved covers every reserving contract, including ones other than the configured LetterOfCredit contract. Count each reservation once: keep available and reserved apart from any other listing of the same reservations.

// Every token this account holds in the vault, across all pages.
const balances: TokenCollateralBalance[] = [];
let cursor: string | undefined;
do {
const page = await sdk.vault.getCollateralBalances({ cursor });
balances.push(...page.items);
cursor = page.nextCursor ?? undefined;
} while (cursor);

Balance and registry listings default to the profile’s current vault. Each result keeps its vault identity, and tokens are identified by address rather than symbol. To inspect or fund a position in an older compatible vault, select it explicitly:

const balance = await sdk.vault.getCollateralBalance({
accountAddress,
tokenAddress: WETH,
collateralContractAddress,
});
const feeBasisPoints = await sdk.vault.getWithdrawalFeeBasisPoints({
collateralContractAddress,
});
console.log(collateralContractAddress, WETH, balance, feeBasisPoints);
// Pass the same explicit vault to buildWithdrawWorkflow or
// buildDepositWorkflow.
// The profile still selects the chain and ABI; its current vault is
// unchanged.

Pass the same vault to its balance, allowance, deposit, withdrawal, and authorization calls. A vault authorization signs for that vault’s EIP-712 verifying contract. Selecting a vault leaves the SDK profile and its LetterOfCredit contract as they are.

For a new-deposit picker, sdk.loc.getCollateralTokens({ enabled: true }) lists enabled collateral. For an existing position, use its actual token address and read that vault’s configuration for it. Product-specific labels for a reservation, such as staking or Beneficiary-contract roles, belong to your app. Available vs. reserved balance explains the vault model.

Reference