Skip to content
Payvol

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');
Output, captured from a real run
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.