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:
| Outcome | What you see | What to do |
|---|---|---|
| Valid | The 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 terms | The promise resolves to a non-empty ValidationIssue[]. | Show each issue next to the field it names. |
| Service failure | The 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. |
| Cancellation | The 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
BaseErrorkeeps that exact instance. A recognizable provider or transport failure becomesProviderConnectionError. Any other exception, or a rejection with a non-Error value, becomesContactSupportError, which is not worth retrying. A wrapped failure keeps the original ascause, and a cancellation keeps the signal’s exact abort reason. - Missing inputs. A rule whose own required field is missing reports
FieldRequiredas an issue. A rule that depends on some other field waits quietly until that field is present. - Incremental validation. While an operation-level
erroris set, incremental validation holds backfields[*].errorsandallErrors. 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.syncis a bug in that rule.runValidationcalls 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.