Skip to content

Validate before signing

Outcome: you know whether a proposed LOC would be accepted, and which fields to fix if it wouldn’t, before the wallet opens.

You need: the read setup plus an accountAddress, so balance checks know whose balances to read. An address is all it takes: validation sends no transaction and runs without a walletClient.

Every write operation has a matching validate* method. It returns problems as data: an empty array means the terms are valid, and each issue names the field it concerns. This snippet checks a 25 USDC LOC for 30 days against real testnet state. The account is the Creator of the demo LOC, funded so the balance check has something to find, the Beneficiary is that LOC’s Beneficiary, and the token address comes from the profile:

import { createPublicClient, http } from 'viem';
import { sepolia } from 'viem/chains';
import {
AnvilSDK,
type CreateStaticLOCParams,
testnetProfile,
} from '@anvil/sdk/core';
const sdk = new AnvilSDK({
publicClient: createPublicClient({
chain: sepolia,
transport: http('https://your-rpc-url'),
}),
profile: testnetProfile,
// In an app this is the connected user (wagmi's `useAccount().address`).
accountAddress: '0x50f91632bd0fb4d4521718e73db3c59ecee69351',
});
const params: CreateStaticLOCParams = {
beneficiary: '0x3c5bea6f8edba748330ad0c0e19bea6731ac57dd',
tokenAmount: {
tokenAddress: testnetProfile.contracts.USDC.address,
amount: 25_000_000n, // 25 USDC at 6 decimals
},
expirationTimestampSeconds: BigInt(
Math.floor(Date.now() / 1000) + 86400 * 30
),
};
// An empty array means the params are valid.
const issues = await sdk.loc.validateCreateStatic(params);
console.log(issues);

Expected output

Both outputs below are unedited results of running this page’s snippet on 2026-08-20 against a public Sepolia RPC. They depend on live protocol state (the account’s balances and the configured expiration limits), so yours can differ. With the parameters as written, every check passes:

[]

Now set the expiration to a day in the past:

expirationTimestampSeconds: BigInt(
Math.floor(Date.now() / 1000) - 86400
),

The same call returns the failing check:

[ { code: 'ExpirationTooSoon', field: 'expirationTimestampSeconds' } ]

The four outcomes

A validate* call ends in exactly one of these:

OutcomeWhat you seeWhat to do
ValidThe promise resolves to []: every check ran and passed. A check whose required field is missing reports FieldRequired, so [] always means the terms were fully checked.Submit.
Invalid termsThe promise resolves to a non-empty ValidationIssue[].Show each issue next to the field it names.
Service failureThe promise rejects with a typed SDK error: one a rule already threw, ProviderConnectionError, or ContactSupportError.Offer a retry or a setup message for the whole operation.
CancellationThe promise rejects with the abort reason you passed.Drop the check quietly; you canceled it.

A resolved issue list is always a complete verdict on the terms you supplied. When a check can’t run, the promise rejects instead, and no partial list comes back.

Reading the issues

Each ValidationIssue carries the field it concerns and a stable code naming the check, so a form can switch on the code and attach each issue to its input. Validation accepts partial parameters, so you can validate as the user types as well as on submit.

A failed RPC or subgraph request, or an abort, rejects the promise rather than becoming a field issue. Catch it around the call and show it as feedback about the operation:

try {
const issues = await sdk.loc.validateCreateStatic(params);
// Render issues next to their fields.
} catch (error) {
// Retry or show operation-level service/setup feedback.
}

Narrowing the two typed service failures lets you decide which one to retry:

import { ContactSupportError } from '@anvil/sdk/core';
import { ProviderConnectionError } from '@anvil/sdk/core';
import type { ValidationIssue } from '@anvil/sdk/core';
type ValidationOutcome =
| { readonly kind: 'issues'; readonly issues: ValidationIssue[] }
| { readonly kind: 'retry' }
| { readonly kind: 'unavailable'; readonly cause: unknown };
async function validateOrClassifyFailure(): Promise<ValidationOutcome> {
try {
const issues = await sdk.loc.validateCreateStatic(params);
return { kind: 'issues', issues };
} catch (error) {
if (error instanceof ProviderConnectionError) {
// Recognizable RPC/subgraph failure -- safe to retry.
return { kind: 'retry' };
}
if (error instanceof ContactSupportError) {
// Unclassifiable failure -- not retryable; surface it as-is.
return { kind: 'unavailable', cause: error };
}
// A typed SDK error a rule already threw, or caller cancellation --
// propagate unchanged.
throw error;
}
}
console.log(await validateOrClassifyFailure());

Why execution validates again

A form check gives early feedback. Balances, authorization nonces, oracle prices, and LOC state can all change between that check and the signature. So every workflow keeps a fixed copy of what you asked for, and runs the operation’s full validation again when execute() reaches the next new wallet prompt.

That execute-time check has four outcomes:

  • executed: the check passed, and the result carries the wallet operations with their current statuses;
  • invalid: the result carries the current issues, and every queued operation stays queued;
  • failed: the result carries a typed SDK error, and the same workflow can run the check again;
  • cancelled: no new wallet prompt began.

The check runs once per execute(), before the first new prompt. The steps within one run go ahead without rechecking, because an earlier step may deliberately change the state a later one depends on. The check never appears in the operation list and never marks an operation Failed or Rejected.

Re-executing a workflow to resolve an Indeterminate transaction that was already submitted checks its receipt before any validation runs, and asks for no new signature. An Indeterminate operation with no transaction hash, whose send failed in transit, stays as it is: re-executing runs no validation and doesn’t resend it.

The same pattern covers every operation: sdk.loc.validateCreateDynamic, validateExtend, validateCancel, validateRedeem, validateModifyCollateral, and the vault’s validateDeposit and validateWithdraw. Each is documented alongside the operation it guards, on LOCModule and VaultModule. All of them return ValidationIssue, and the wider error family is indexed under Errors.

Error handling in detail

  • Which error you get. A rule that already threw a BaseError keeps that exact instance. A recognizable provider or transport failure becomes ProviderConnectionError. Any other exception, or a rejection with a non-Error value, becomes ContactSupportError, which is not worth retrying. A wrapped failure keeps the original as cause, and a cancellation keeps the signal’s exact abort reason.
  • Missing inputs. A rule whose own required field is missing reports FieldRequired as an issue. A rule that depends on some other field waits quietly until that field is present.
  • Incremental validation. While an operation-level error is set, incremental validation holds back fields[*].errors and allErrors. A field whose async check failed goes back to provisional (unchecked) rather than showing a fresh error, so the UI keeps the field’s last good state instead of flashing it invalid.
  • Synchronous rules return issues. A throw from rule.sync is a bug in that rule. runValidation calls synchronous rules outside the boundary that classifies async failures, so a synchronous rule reports every problem with the terms as an issue.

Next: create a LOC, taking the same parameters through validate, build, and execute.