Working with Records
Records are Aleo's private state, and they are analogous to UTXOs in
Bitcoin. A record is created as the output of a program function; once
another function consumes it as an input, it is spent and can never be used
again. A private balance — a credits.aleo holding, a token, a
program-issued asset — is the sum of every unspent record a program has ever
produced for that owner. Finding that balance, or finding a specific record
to spend, means scanning the chain for records the caller owns and checking
which of them are still unspent.
Veil hides that scan behind requestRecords.
Which mechanism actually performs the scan depends on where the account's
signing key lives.
Records from a connected wallet
An RPC account — one built with fromWalletAdapter or useVeilWallet —
delegates the scan to the connected wallet, which maintains its own record
index. No scanner configuration is needed on the client:
import { fromWalletAdapter } from '@provablehq/veil-aleo-wallet-adapter'
import { createWalletClient } from '@provablehq/veil-core'
const { account, transport } = fromWalletAdapter(connectedAdapter)
const walletClient = createWalletClient({ account, transport })
const records = await walletClient.requestRecords({
program: 'loyalty_token.aleo',
statusFilter: 'unspent',
})
Records from a local account
A local account has no wallet to ask, so the wallet client needs its own
recordProvider. loadNetwork's handle exposes two scanner factories, both
backed by Provable's Record Scanning Service (RSS):
import { createWalletClient, http } from '@provablehq/veil-core'
import { loadNetwork } from '@provablehq/veil-aleo-sdk'
const aleo = await loadNetwork('testnet')
const account = aleo.privateKeyToAccount('APrivateKey1...')
const walletClient = createWalletClient({
account,
transport: http('https://api.provable.com/v2', { network: 'testnet' }),
proving: aleo.createProvingConfig({
mode: 'delegated',
networkUrl: 'https://api.provable.com/v2',
account,
}),
recordProvider: aleo.createRemoteScanner({
url: 'https://api.provable.com/v2',
consumerId: '<consumer-id>',
}),
})
const records = await walletClient.requestRecords({
program: 'loyalty_token.aleo',
statusFilter: 'unspent',
})
createRemoteScanner's first call registers the account's view key with the
service to obtain a scanning session; subsequent calls reuse it. A wallet
client built without a recordProvider throws as soon as a local account
calls requestRecords.
Records without a wallet client
A read-only integration — a dashboard, an audit tool — that needs records
but never signs a transaction has no reason to build a wallet client at all.
createStandaloneScanner takes an explicit view key and extends a public
client with withRecords:
import { createPublicClient, http, withRecords } from '@provablehq/veil-core'
import { loadNetwork } from '@provablehq/veil-aleo-sdk'
const aleo = await loadNetwork('mainnet')
const scanner = aleo.createStandaloneScanner({
url: 'https://api.provable.com/v2',
consumerId: '<consumer-id>',
viewKey: 'AViewKey1...',
})
const viewClient = createPublicClient({
transport: http('https://api.provable.com/v2', { network: 'mainnet' }),
}).extend(withRecords({ scanner }))
const records = await viewClient.requestRecords({ program: 'loyalty_token.aleo' })
This scanner is not pluggable into a wallet client's recordProvider — use
createRemoteScanner there instead. createStandaloneScanner exists
specifically for the no-signer case, and the resulting client can only
scan — it has no writeContract to spend what it finds. The rest of this
guide assumes a wallet client from one of the first two paths, since
spending a record requires signing.
The record shape
Every entry requestRecords returns carries programName, recordName,
owner, spent, and the transaction and transition coordinates that
produced it. With the default includePlaintext: true, each entry also
carries recordPlaintext — the decrypted record body:
{
programName: 'loyalty_token.aleo',
recordName: 'LoyaltyCard',
spent: false,
recordPlaintext: '{\n owner: aleo1....private,\n card_id: 123field.private,\n points: 500u64.private,\n _nonce: ...group.public\n}',
...
}
Set includePlaintext: false to get ciphertext-only entries instead — useful
when the caller only needs to know which records exist, not their contents.
statusFilter narrows the scan to 'unspent', 'spent', or 'all'
(the default).
Filtering for spendable records
A record can only be spent once. Filter for spent: false before using one
as a function input — passing a spent record fails the transaction:
const unspentCards = records.filter(
(r) => r.recordName === 'LoyaltyCard' && !r.spent,
)
Pass the recordPlaintext string as the input, the same way any other
literal input is passed:
const card = unspentCards[0]
const txId = await walletClient.writeContract({
program: 'loyalty_token.aleo',
function: 'add_points',
inputs: [card.recordPlaintext, '100u64'],
})
Decrypting a record directly
A record ciphertext received outside requestRecords — from a program
output, or fetched from a transaction — decrypts with
decryptRecord, given the owner's view
key. This is pure and local; no network round trip is made and the view key
never leaves the caller:
const plaintext = aleo.decryptRecord(account.viewKey, 'record1qyqsq...')
Refreshing before spending
A record fetched some time ago may already be spent by the time it is used — by a transaction still pending elsewhere, or one that landed since the last scan. Refresh immediately before spending rather than trusting a previously fetched list:
const fresh = await walletClient.requestRecords({
program: 'loyalty_token.aleo',
statusFilter: 'unspent',
})
const card = fresh.find((r) => r.recordName === 'LoyaltyCard')
if (!card) {
throw new Error('No unspent card available')
}
await walletClient.writeContract({
program: 'loyalty_token.aleo',
function: 'add_points',
inputs: [card.recordPlaintext, '100u64'],
})
After a transaction that consumes or creates records is accepted, allow a
moment for the scanner to catch up to that block before expecting
requestRecords to reflect the change — see
Transaction Lifecycle for the acceptance
and propagation timing in full.
Records as wallet-fulfilled inputs
The examples above pass a record's plaintext directly as a literal input,
which works for both account types. An RPC account has a second option: an
InputRequest object that asks the connected wallet to select and supply the
record itself, so the plaintext never has to pass through the caller at all.
inputs: [
{
type: 'record',
program: 'loyalty_token.aleo',
recordname: 'LoyaltyCard',
filters: { points: { gte: '100u64' } },
},
'100u64',
]
filters narrows the wallet's automatic selection by record field; uid —
the opaque handle a prior requestRecords call attached to a record — pins
the request to that exact record instead. The two are mutually exclusive.
Wallets that predate the privacy feature do not attach a uid to their
records, so check for its presence before relying on it.
Local accounts do not support InputRequest inputs; writeContract and
executeContract throw if one is passed on that path, since there is no
wallet to resolve it against.