Guides
Observe settlements on your node
Reading payments off your own participant and asking what they settle. Payvol indexes nothing: everything here runs against a ledger response you already have.
What an observation is
recordFromUpdate takes a raw Ledger API update response and produces a durable
record: the update id, the transfer contract id, the digest and profile from the settlement
metadata, the instrument, amount, receiver and the time the offer was made, plus a hash of the
source response so the record can be traced back to what produced it.
An offer is not a payment
Creating a TransferInstruction is an offer awaiting the
receiver's decision. Every record says which it is in lifecycle, and only
settled is evidence that value moved. An offer, a rejection and a withdrawal are
correlation evidence: they tell you which request a transfer belongs to and nothing about
whether it completed.
resolveOffer combines an offer record with the update that ended it. A settlement
needs all three of a consuming exercise on that offer's own contract, a choice
your policy names as an accept, and a result carrying the completed tag and
naming holdings created in that same update. A consuming event alone is not
an acceptance — Archive consumes too.
toSettlementObservation refuses anything that is not settled, so a record you
persisted before the outcome was known cannot become a payment after a restart. An observation
that does not state a lifecycle reads as unknown, never as
settled: a record written before 0.4.0 has no such field, and reading its absence as a
settlement is how a persisted offer that a ledger had rejected reconciled as paid again.
migrateLegacyRecord rebuilds such a record against the updates.
What proves the value arrived
A settlement needs the holdings the acceptance credited, and a contract id in a list is not
evidence about them. Every id the result names must be created in that same update and carry a
Holding interface view — the token standard's own
{owner, instrumentId, amount}, the same shape for every registry, rather
than one registry's private payload. The owner must be the offer's receiver and the instrument
the offered one, or the update is not a coherent resolution of that offer. A short amount is
not: it settles, and reconciliation decides partial.
The Ledger API returns that view only when your query asks for the interface.
A wildcard filter alone comes back with interfaceViews: [] and no settlement can be
verified from it. Add the interface filter, with a package name and a leading
#:
{ identifierFilter: { InterfaceFilter: { value: {
interfaceId: '#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding',
includeInterfaceView: true,
} } } } What changed the record
Every resolved record carries resolution: the update, the contract, the choice, the
ledger time and a content hash of the raw response. So a record whose lifecycle changed can say
which event changed it after the source history is pruned. The hash detects a changed copy; it
does not prove where the response came from, which is your participant's authentication.
Trust is your configuration
A TransferTrustPolicy says which package, module and entity you accept as a
transfer instruction, which interfaces the resolving exercise may be reported through, what
each choice name proves, and which result tag means completed. It comes from your application
configuration — never from the artifact, and never from the update being examined.
This library ships no default and no package ids. Which packages a network
runs is an external fact that differs between networks, and core cannot discover it. Without a
policy, recordFromUpdate produces no record at all: no trust configuration means
no trusted settlement. The LocalNet guide shows a policy
read off a real network, with the update ids it came from.
It reads the created contract inside the update, never the submitted payload. A value present in a transaction you sent can be absent from the contract it creates, so verification that stops at what was sent is not verification.
It returns {ok: false, missing} rather than a partial record. A record
missing a required field, or carrying one that cannot be parsed, would store a false sense of
evidence.
Asking what it settles
// Turning a ledger update into evidence, and asking what it settles.
import {
payloadDigest,
recordFromUpdate,
toSettlementObservation,
reconcile,
} from '@payvol/core/draft02';
const payload = {
version: '0.2-draft',
type: 'transfer',
executionScope: { network: 'canton-devnet', synchronizerPolicy: 'any' },
payee: { party: 'payee::1220aabbcc' },
requestedReceive: {
instrument: { admin: 'DSO::1220be58', id: 'Amulet' },
amount: '100',
destination: { owner: 'payee::1220aabbcc', provider: 'DSO::1220be58', id: '' },
},
expiry: '2027-01-01T00:00:00Z',
};
const digest = payloadDigest(payload);
console.log('digest ', digest.slice(0, 24), '…');
const observation = (id, amount, patch = {}) => ({
observationId: id,
// What this is evidence OF. Only 'settled' feeds the axes; an omitted
// lifecycle reads as unknown, because a record that does not say cannot close.
lifecycle: 'settled',
updateId: `update-${id}`,
digest,
profile: '0.2-draft',
instrument: { admin: 'DSO::1220be58', id: 'Amulet' },
amount,
receiver: 'payee::1220aabbcc',
effectiveAt: '2026-09-01T10:00:00Z',
observedAt: '2026-09-01T10:00:05Z',
...patch,
});
const policy = {
allowPartial: true,
evidenceWindow: { from: '2026-01-01T00:00:00Z', to: '2028-01-01T00:00:00Z' },
};
// Two payments that together reach the amount asked for.
const whole = reconcile(payload, digest, [observation('a', '50'), observation('b', '50')], policy);
console.log(
'two payments ',
whole.status.amount,
'/',
whole.closureReadiness,
'/ flags',
whole.flags.length,
);
// The same, with one payment whose settlement time cannot be read.
const partial = reconcile(
payload,
digest,
[observation('a', '50'), observation('b', '50', { effectiveAt: 'invalid' })],
policy,
);
console.log('one bad time ', partial.status.timing, '/', partial.closureReadiness);
console.log(' named ', partial.malformed?.[0]);
// Nothing observed at all, and no window that could have seen it.
const nothing = reconcile(payload, digest, [], { allowPartial: true });
console.log('no evidence ', nothing.closureReadiness, '/', nothing.flags.join(','));
// The adapter refuses a malformed ledger response rather than throwing.
const refused = recordFromUpdate(
{ update: { Transaction: { value: { events: [null] } } } },
{
observationId: 'obs-1',
observedAt: '2026-09-01T10:00:05Z',
metadataKeys: { digest: 'payvol.xyz/request-digest', profile: 'payvol.xyz/profile' },
trust: {
offerTemplates: [{ packageId: 'pkg', module: 'Registry', entity: 'TransferInstruction' }],
offerInterfaces: [],
holdingInterfaces: [],
choices: { accept: ['Accept'], reject: ['Reject'], withdraw: [] },
completedResultTag: 'Completed',
},
},
);
// And it refuses when no trust policy says what a transfer instruction IS.
const untrusted = recordFromUpdate(
{ update: { Transaction: { value: { events: [] } } } },
{
observationId: 'obs-2',
observedAt: '2026-09-01T10:00:05Z',
metadataKeys: { digest: 'payvol.xyz/request-digest', profile: 'payvol.xyz/profile' },
},
);
console.log('no trust ', 'ok =', untrusted.ok, '/', untrusted.ok ? '' : untrusted.missing[0]);
console.log(
'bad response ',
'ok =',
refused.ok,
'/ missing',
refused.ok ? 0 : refused.missing.length,
);
console.log('narrowed ', typeof toSettlementObservation === 'function'); digest 5a3e63d74ee7544b115a6857 …
two payments exact / eligible-for-closure / flags 0
one bad time unknown / requires-review
named b: effectiveAt is not an RFC 3339 date-time
no evidence not-satisfied / missing-evidence
no trust ok = false / trust (no TransferTrustPolicy was supplied, so no template can be trusted)
bad response ok = false / missing 8
narrowed true
Output from @payvol/core version 0.4.0 and its sibling
packages, installed from npm, on 2026-10-18.
Five axes, not one verdict
reconcile answers separately on amount, timing, instrument, receiver and policy,
and only then reports a closureReadiness. Any axis it cannot decide is
unknown, never a guess.
eligible-for-closure— every axis agreed and no flag was raised.requires-review— something wants a person: a flag, an overpayment, lateness, an unknown axis.not-satisfied— a hard disagreement: wrong receiver, wrong instrument, short total, or an amount it could not compute.
allowPartial: false means an incomplete total does not satisfy
the request. Several payments that together reach the amount do satisfy it. A one-payment-only
rule is a separate requirement this field does not express.
When the evidence is bad
Observations come from adapters reading ledgers, so they are untrusted input and the function
is total over them. A field that is absent, null, the wrong type or present-but-unreadable
marks that observation malformed with the failing field named, degrades the axis it fed to
unknown, and makes eligible-for-closure unreachable while anything
in the set is malformed. Nothing throws.
This behaviour is in 0.3.1 and later. On earlier versions a present-but-invalid settlement time could be vouched for by a valid sibling observation and reach closure.
What it will not decide
- Whether to close an invoice. That is a policy decision; this is the evidence for it.
- Which of two observations of the same update is the real one. Duplicates are flagged, not resolved.
- Anything about payments your party cannot see. Payvol reads your view, and your view is not the network.