Skip to content

Create and redeem a LOC

Outcome: you take one Letter of Credit through its whole life. By the end, a Static LOC has held real test collateral on Sepolia for a named Beneficiary, you have confirmed it on Etherscan and in the subgraph, and the Beneficiary has redeemed it. The contract then removes the LOC, and the credited tokens sit in the Beneficiary’s wallet.

The Start pages each show one call. This page is the sequence: what the SDK decides for you, what your app still owns, what to check between steps, and what to do when a step doesn’t go through.

Why a Static LOC

Start with a Static LOC. It uses the same token as collateral and credit, so there’s no oracle price to fetch, nothing that can be liquidated, and only one test token to fund. Once this round trip works, a Dynamic LOC is the same three steps with a price update added. How Static and Dynamic LOCs differ

You need

  • The SDK. During the partner preview, Get access explains how to get the package.
  • A Sepolia RPC URL.
  • A Sepolia account with ETH for gas. Every transaction in the sequence costs Sepolia ETH, which any public Sepolia faucet provides. The test token you’ll reserve as collateral is self-serve: any account can mint itself a balance of the testnet collateral tokens, and step 1 ends by doing that. This is the partner-preview testnet: use test assets only.
  • A private key you’re willing to keep in a script. The snippets sign locally with viem’s privateKeyToAccount because it’s the shortest runnable path. In an app, the same calls take a browser wallet’s WalletClient instead, and everything else stays the same.

One wallet plays both parties

A LOC has a Creator (the Issuer), who reserves the collateral, and a Beneficiary, who can redeem it. In production they’re different businesses. The protocol and the SDK’s validation both accept a LOC whose Creator is also its Beneficiary, so this walkthrough uses one funded account for both roles and you can watch the whole round trip from a single terminal.

Redemption here is the Beneficiary’s own action: the code redeems as the Beneficiary, which is the same code your counterparty would run. A Beneficiary can also sign an EIP-712 authorization and let someone else submit the redemption. Redeem, convert, and act for the Beneficiary covers that path, and RedeemLOCsParams has its parameters.

What the SDK handles, and what your app handles

  • The SDK plans and runs the steps. It checks the parameters against live protocol state before the wallet is involved, works out which approval, vault deposit, and typed-data signature steps this account still needs, grosses the collateral up to what the vault will reserve, submits each step in order and waits for its confirmation, and reports every step’s outcome as typed data.
  • Your app owns the business and the wallet. It decides the Beneficiary, amount, and expiration; connects and manages the wallet; keeps the account funded with gas and the collateral token; shows the user what’s happening; and decides what a failed step means for your business.

The step planning is what surprises people. You describe the LOC you want, and the SDK reads the account’s current vault balance and allowance and plans only the steps still missing: the approval, the vault deposit, the allowance signature, and the create transaction, in order. The same parameters produce a multi-step workflow on a fresh account and a single step on an account that has already deposited and authorized.

1. Configure the SDK

The profile carries the testnet contract addresses, subgraph URL, and chain id, generated from the protocol’s deployment record. You copy no addresses, and the SDK checks that the profile matches the connected chain before it runs.

import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { sepolia } from 'viem/chains';
import { AnvilSDK, testnetProfile } from '@anvil/sdk/core';
const transport = http('https://your-sepolia-rpc-url');
// One wallet plays both parts of this walkthrough: it creates the LOC and
// it is the beneficiary that redeems it.
export const account = privateKeyToAccount('0xyour-private-key');
const publicClient = createPublicClient({ chain: sepolia, transport });
const walletClient = createWalletClient({
account,
chain: sepolia,
transport,
});
export const sdk = new AnvilSDK({
publicClient,
walletClient,
// Contract addresses, subgraph URL, and chain id for the public
// testnet, generated from the protocol's deployment record.
profile: testnetProfile,
});

Every snippet on this page continues that same module, in order. Each one carries only the imports its own step adds and uses the values and types earlier steps imported, so a snippet lifted out on its own may need an import or two from before it. The step notes say which.

Mint the test collateral

The account needs a balance of the token it’s about to reserve. On the testnet that’s one transaction the account sends itself, because any account can call the token’s mint function there:

export async function mintTestCollateral(): Promise<void> {
// The testnet collateral tokens are open-mint: any account can mint
// itself a balance. 1,000 test USDC at 6 decimals.
const { USDC } = testnetProfile.contracts;
const hash = await walletClient.writeContract({
address: USDC.address,
abi: USDC.abi,
functionName: 'mint',
args: [account.address, 1_000_000_000n],
});
await publicClient.waitForTransactionReceipt({ hash });
}

Run it once per account; the balance covers many walkthroughs at 25 test USDC each.

2. Describe the LOC, and validate it

Validation is a read. It uses no wallet, sends no transaction, and returns its findings as data. An empty array means the parameters pass every check the SDK can make in advance.

import { dateToEthereumTimestamp } from '@anvil/sdk/core';
import type { CreateStaticLOCParams } from '@anvil/sdk/core';
export const createParams: CreateStaticLOCParams = {
beneficiary: account.address,
// 25 test USDC at 6 decimals. For a static LOC this one amount is both
// the face value and the collateral: same token, one to one.
tokenAmount: {
tokenAddress: testnetProfile.contracts.USDC.address,
amount: 25_000_000n,
},
// Seven days out. `sdk.loc.getMinDate()` and `sdk.loc.getMaxDate()`
// report the bounds this deployment currently accepts.
expirationTimestampSeconds: dateToEthereumTimestamp(
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
),
};
export async function validateCreate(): Promise<void> {
// No wallet interaction. An empty array means every check passed.
const issues = await sdk.loc.validateCreateStatic(createParams);
if (issues.length > 0) throw new Error(JSON.stringify(issues));
}

Each issue names the field it concerns and a stable code, so a form can attach each one to its input. Validate before signing covers the issue shape and how to tell “fix your input” from “the RPC call failed, try again”.

Check the collateral before you sign. The vault reserves the face value plus its withdrawal fee, so the Beneficiary can later claim the whole face value. sdk.loc.getRequiredCollateralForStatic({ collateralTokenAmount }) returns that grossed-up figure, and it’s the amount the workflow makes sure your vault balance covers, which may be more than the 25 USDC you asked for. Called with collateralTokenAmount: 25_000_000n against the public testnet on 2026-08-20, it returned 25000000n, because the fee was zero there at the time. By 2026-09-23 the testnet vault’s withdrawal fee was 50 basis points, so the same call now asks for more than the face value.

The fee is live configuration that changes, so read it each time, on testnet and especially on mainnet.

3. Build the workflow, then execute it

buildCreateStaticWorkflow returns the plan before it runs anything. workflow.operations tells your UI in advance how many wallet prompts to expect.

execute() checks everything again. It reruns the full validation once, against fresh protocol state, just before the first new wallet prompt. That check can resolve invalid with issues about the terms, failed with a typed SDK error, or cancelled, and each of those leaves every queued operation as it was. Only executed carries the operation-status snapshot.

Every build*Workflow method also pins the workflow to the signer it saw when it was built. WorkflowSignerChangedError is thrown from execute() if the connected wallet changes before it finishes, and from build*Workflow itself if the SDK’s configured accountAddress and the connected wallet already disagree. Rebuild the workflow for the connected signer; the same workflow executes again only once the original signer reconnects.

import { WalletOperationStatus } from '@anvil/sdk/core';
export async function createLOC(): Promise<void> {
const workflow = await sdk.loc.buildCreateStaticWorkflow(createParams);
// The planned steps, before the wallet is asked for anything.
console.log(workflow.operations.map(operation => operation.type));
const execution = await workflow.execute(update => {
for (const operation of update) {
console.log(`${operation.type}: ${operation.status}`);
}
});
if (execution.status !== 'executed') {
throw new Error(`creation preflight ended as ${execution.status}`);
}
const creation = execution.operations.at(-1);
if (creation?.status !== WalletOperationStatus.Confirmed) {
throw new Error(`creation did not confirm: ${creation?.status}`);
}
console.log(`created in ${creation.transactionHash}`);
}

The plan depends on the account, so here’s a real one. Building this workflow for a testnet account that already held enough vault balance, but hadn’t yet authorized the LetterOfCredit contract to reserve it, produced two steps. Here is that workflow.operations, each entry reduced to its type and status (captured 2026-08-20, before any wallet interaction):

[
{ type: 'VaultAllowanceSignature', status: 'Queued' },
{ type: 'StaticLOCCreation', status: 'Queued' }
]

A fresh account gets more: an ERC20Approve and a VaultDeposit ahead of those, because the token must be approved to the vault and deposited before it can be reserved. An account that has already deposited and authorized gets one step, the creation alone. The builder reads the chain and decides.

Each step prompts the wallet once. execute() resolves when the workflow stops, after the last step or as soon as one fails, and returns every operation with its final status. Transaction steps carry a transactionHash, and the signature step carries signature. The progress handler receives the same array on every status change, and that’s what you render.

4. Read the LOC back, and verify it independently

Confirm the new LOC three ways rather than relying on execute() alone.

From the SDK. The subgraph indexes the creation event into a listing, and the contract holds the authoritative state.

import type { OutstandingLetterOfCredit } from '@anvil/sdk/core';
export async function newestStatic(): Promise<OutstandingLetterOfCredit> {
// Subgraph-backed listing, ordered by ascending id, so the LOC you just
// created is the last entry of the last page. Indexing runs behind the
// chain: poll rather than expecting it the instant creation confirms.
const filter = {
creators: [account.address],
creationKind: 'static' as const,
};
const locs: OutstandingLetterOfCredit[] = [];
let page = await sdk.loc.getOutstandingLetterOfCredits(filter);
locs.push(...page.items);
while (page.nextCursor !== null) {
page = await sdk.loc.getOutstandingLetterOfCredits({
...filter,
cursor: page.nextCursor,
});
locs.push(...page.items);
}
const newest = locs.at(-1);
if (!newest) throw new Error('no static LOC indexed for you yet');
// Authoritative read: contract state, no subgraph involved.
const loc = await sdk.loc.getOutstandingLetterOfCredit({
id: newest.reference.id,
});
if (!loc)
throw new Error(`LOC ${newest.reference.id} is no longer outstanding`);
return loc;
}

Those two calls answer different questions, which is why Where data comes from exists. getOutstandingLetterOfCredits reads the subgraph, which runs behind block production, so a LOC created seconds ago may not be listed yet, and polling for it is the right approach. getOutstandingLetterOfCredit reads contract storage and is authoritative at the block it read. An app that needs the new id immediately can decode it from the creation event in the transaction receipt it already has.

The live position of a Static LOC has creationKind: 'static' and reserved backing in the same token as remainingCredited. It leaves out liquidation parameters and token metadata, so resolve the token separately for display. Use sdk.loc.getLetterOfCredit({ id }) for the full record, with its origin and lifecycle, including after the LOC resolves. Read a LOC shows the full record’s shape.

From Etherscan. Paste the creation transactionHash into sepolia.etherscan.io. A successful creation shows the LetterOfCredit contract’s creation event in the logs, carrying the new LOC’s id, its parties, and its amounts. If the workflow included a deposit step, that’s a separate transaction, and it’s the one that shows the token moving into the Collateral Vault; creation reserves a balance the vault already holds. This check runs entirely outside Anvil’s software, which is the point of running it.

From the subgraph. The outstanding listing finds candidates through the subgraph and confirms them onchain. A getLetterOfCredit read returns the full record instead: origin, lifecycle, and creation tag, as of what the subgraph has indexed, reported as indexedAt. Those historical facts come only from the subgraph, since contract storage holds just the live position.

5. Redeem it

Redemption is the Beneficiary’s move. The contract checks the caller against the LOC’s Beneficiary: when they match, the call needs no authorization signature, and the redeemed tokens go to the caller by default. This walkthrough’s one wallet is the Beneficiary, so that’s the path the code takes.

This step’s snippet takes an OutstandingLetterOfCredit, the type step 4 imported, so lift it out with that import alongside RedeemLOCsParams.

import type { RedeemLOCsParams } from '@anvil/sdk/core';
export async function redeemLOC(
loc: OutstandingLetterOfCredit
): Promise<void> {
// The connected wallet is this LOC's beneficiary, so the redemption
// needs no beneficiary authorization: one entry, the full remaining
// face value, delivered to the signer.
const params: RedeemLOCsParams = {
redemptions: [
{ id: loc.reference.id, amount: loc.remainingCredited.amount },
],
};
const issues = await sdk.loc.validateRedeem(params);
if (issues.length > 0) throw new Error(JSON.stringify(issues));
const workflow = await sdk.loc.buildRedeemWorkflow(params);
const execution = await workflow.execute();
if (execution.status !== 'executed') {
throw new Error(`redemption preflight ended as ${execution.status}`);
}
const [redemption] = execution.operations;
console.log(redemption?.status, redemption?.transactionHash);
// A full redemption purges the LOC, so this now resolves `undefined`.
console.log(
await sdk.loc.getOutstandingLetterOfCredit({ id: loc.reference.id })
);
}

Three things about that call are rules of the contract, so they’re worth stating exactly:

  • Redeeming the full remainingCredited.amount settles the LOC, and the contract removes its live position. The full record keeps its origin and lifecycle history. Redeeming less is a partial redemption: the LOC stays open with the remainder, and you can make the same call again. validateRedeem rejects an amount larger than what remains.
  • The tokens land in the destination account’s own balance, transferred out of the vault as ordinary ERC-20 tokens rather than credited inside the vault. A direct redemption defaults destinationAddress to the connected signer; a delegated redemption with beneficiaryAuthorization defaults it to the LOC’s Beneficiary. Set it explicitly when the Beneficiary wants the proceeds elsewhere.
  • Redeem before the expiration. Expiration is a hard cutoff for redemption, and after it anyone can cancel the LOC to release its collateral back to the Creator. Redeem before the expiration you set, or the round trip ends in cancellation instead.

What the numbers do. For a full redemption, the Beneficiary receives exactly the face value. The Creator’s vault balance drops by the whole reserved amount, face value plus the withdrawal fee reserved alongside it, and the vault keeps that fee. When the fee is zero, the round trip is exact apart from gas; when it’s above zero, a create-then-redeem cycle costs the Creator that fee. Read the fee each time.

Redeeming for someone else is the same redeemLOC call with one field added. The Beneficiary signs an EIP-712 authorization binding the LOC id, the amount, the LOC’s current credited amount, the destination, and their next nonce, and the redeemer passes those bytes as beneficiaryAuthorization with the same id, amount, and destination. Batching several redemptions in one transaction needs an authorization for every entry, because the contract then sees the Multicall contract as the caller rather than the Beneficiary.

When a step doesn’t go through

Wallet-operation failures come back as data. After a valid preflight, execute() resolves executed with the operations and their final statuses, and you read each transaction’s outcome from that snapshot. Preflight outcomes are separate: invalid carries issues, failed carries a typed error, and cancelled means no new prompt began. This snippet reads wallet statuses off the WalletOperationStatus enum step 3 imported, so on its own it needs that import too.

import { isIndeterminateWithoutHash } from '@anvil/sdk/core';
import type {
AnvilWalletOperationType,
WalletOperation,
} from '@anvil/sdk/core';
export function describeOutcome(
operations: WalletOperation<AnvilWalletOperationType>[]
): string {
const failed = operations.find(
operation => operation.status === WalletOperationStatus.Failed
);
if (failed) {
// A wallet rejection arrives here as `root: ['RejectedByUser']`; a
// revert arrives as the decoded, per-field reason. Recover by fixing
// the cause and building a fresh workflow.
return `${failed.type} failed: ${JSON.stringify(
failed.validationErrorResponse
)}`;
}
const parked = operations.find(isIndeterminateWithoutHash);
if (parked) {
// The send failed in transit, so it may or may not have landed and
// there is no hash to check. execute() will not resend it: check
// wallet activity before building a fresh workflow.
return `${parked.type} may have been sent: check wallet activity`;
}
const unresolved = operations.find(
operation => operation.status === WalletOperationStatus.Indeterminate
);
if (unresolved) {
// Not terminal: confirmation timed out and the transaction may still
// land. Call execute() on this same workflow to re-check the hash.
return `${unresolved.type} unresolved: ${unresolved.transactionHash}`;
}
return 'every step reached a successful terminal status';
}

Each status has a specific meaning:

  • Failed: the step reverted onchain, the user declined it in the wallet, the SDK couldn’t submit it (the node refused it, or the connection dropped before anything was sent), or the transaction was submitted and the wait for its receipt failed for a reason other than a timeout. In that last case transactionHash is set, so check the chain before rebuilding. The reason is on validationErrorResponse, typed per operation: a decline reads root: ['RejectedByUser'], and a revert is decoded onto the field it concerns (an expiration the contract rejected lands on expirationTimestampSeconds, insufficient vault balance on tokenAmount).
  • Rejected: an earlier step failed, so this one never ran. Every step after a failure is marked this way.
  • Indeterminate: the outcome is unknown. Either the transaction was submitted but confirmation timed out, so it may still land and transactionHash is set; or the connection failed while the transaction was being sent, so it may or may not have landed, there’s no transactionHash, and the step reads root: ['UnknownOutcome']. Steps after it stay Queued.

The recovery rule follows from those, and it’s the easiest thing on this page to get wrong: Failed and Rejected are both terminal, so calling execute() again on the same workflow object changes nothing. It walks the array, finds every remaining step already terminal, and returns executed. To recover from a failure, build a new workflow from the same parameters. The rebuild reads the account’s balance and allowance again, so if the deposit succeeded before the creation reverted, the new workflow comes back without the deposit step.

Indeterminate is the exception: it isn’t terminal. When it carries a transactionHash, running execute() again re-checks that hash rather than resubmitting. When it has no hash, execute() leaves it as it is and never resends it. Check the account’s wallet activity before building a new workflow: a rebuild reads balance and allowance again, but it can’t tell whether the uncertain step itself, such as a LOC creation, already landed.

A preflight that failed on a service problem changes no queued status, so retry the same workflow once the provider or oracle recovers. Invalid terms need corrected parameters and a freshly built workflow.

A user who declines a wallet prompt has spent nothing onchain. A revert has usually cost gas and may have changed something, so check what landed before rebuilding.

Going to production

Everything above is the testnet shape. Four things change for production, and the rest carries over.

  1. Swap the profile. mainnetProfile is exported from @anvil/sdk/core beside testnetProfile and carries the mainnet addresses, subgraph, and chain id. Pair it with viem’s mainnet chain and a mainnet RPC. Environments and profiles covers selecting a profile at runtime and overriding individual fields.
  2. Sign with a wallet, not a key in a script. Production signing is a browser wallet or a custody service handing you a WalletClient; sdk.updateWalletClient() swaps it in when the user connects.
  3. Treat every number as real. The vault withdrawal fee, the accepted expiration bounds, and the supported collateral tokens are per-deployment configuration, and mainnet’s differ from testnet’s. Read them rather than porting constants.
  4. Validate on the way in, as well as before signing. validate* catches what the protocol would reject. It can’t know that your business meant 25 USDC rather than 25,000. Keep Validate before signing in the request path, with your own limits in front of it.

Before real collateral moves, check the Beneficiary address, the token address, the amount’s decimals, and the expiration against your own records. On mainnet, a mistaken Beneficiary means a LOC that only that Beneficiary can release before expiration.