Guides
A payment end to end on LocalNet
On 8 September 2026 a full merchant-path payment ran on a Canton LocalNet started from nothing: two fresh keys, two new parties, a request carrying its own digest, a transfer, the recipient accepting with their own key, and both updates read back independently from the participant. This page is what that run establishes, and what it does not.
What this proves
The parts of Payvol that had never been exercised against a real ledger now have been. In particular:
- A request digest survives the round trip. The digest computed from the payload was written to the transfer's metadata, read back out of an independent Ledger API query, and matched. Nothing in that path trusted the submitting process.
- The observation adapter reads a real response.
recordFromUpdatewas given the participant's actual update JSON, not a fixture written to look like one, and extracted the digest, receiver, instrument, amount and effective time from it. - The merchant path needs no wallet. The ledger reads went directly to the participant's JSON Ledger API.
- Acceptance is separable from submission. Seeing the offer's metadata is not seeing a completed payment. The acceptance update was fetched separately and shown to consume the pending offer and create the recipient's holding contract.
What it does not prove
LocalNet is not DevNet and it is not MainNet. It is a network started on one machine, with its own synchronizer, its own parties and no other participants. A payment settling there says the software and the standard fit together. It says nothing whatever about how either behaves on a public network, under real validators, with other people's traffic and real value.
The same run attempted the public DevNet and was refused: the sequencer endpoint answered
HTTP 403, because a validator's egress address has to be allow-listed first.
So there is no DevNet payment behind this page, and none is claimed.
Also outside what this establishes:
- Refunds, cancellations and any failure path. One payment succeeded; nothing was undone.
- Cross-validator payments, hardware wallets, physical NFC, and any second implementation.
-
Signature verification. The request in this run used
unsignedAllowed, andsignatureVerified: not-checkedmeans no cryptography was verified.
The same review that ran this payment also found five ways the reconciler mishandled malformed input. Those were fixed in 0.3.1, and the guidance that follows from them has not changed: a reconciliation verdict is evidence for a person or a policy to act on, not an instruction to close an invoice unattended.
The run, step by step
- Two Ed25519 keys generated locally. The private keys never left the machine and are in no repository.
- Two external parties, payer and payee, registered on the fresh ledger.
- 1,000 test CC tapped to the payer.
-
A
0.2-draftrequest for 1.5 CC built against the real instrument, validated, rendered to a QR image, read back from the pixels by an independent decoder, and its digest re-derived from the decoded JSON and matched. -
The transfer submitted with
payvol.digestandpayvol.profilein its metadata. - The transfer update fetched by id in a separate query, and the observation extracted and hashed.
- The recipient accepting with their own key; their holding appearing; the offer leaving the pending list.
- The acceptance update fetched independently and shown to consume the offer contract.
The ledger evidence
These are LocalNet identifiers. They are meaningful only against that throwaway network and will not resolve in any public explorer, which is the point of quoting them: they are what was observed, not a claim about a network anyone else can query.
completed 2026-09-08T11:09:53.927Z
transfer update 1220a157517ffc8ed0d02baea8cfaef1d38ce7a17e5b1e37901fa9897b008ce269a7
accept update 1220dca9433d631dbe0d67de51d4f8815aaee06e1c670003babcef3c0c9868280661
tap update 12202de2a3e33511e53e40650d7080e01c85304f8f393669ae467bb87542c881619b
holding after 1.5000000000 CC Reconciling it
This is the second half of the run, replayed from the shape the participant returned. It touches no network: the response is data, and the packages do the rest.
// The reconciliation half of a LocalNet payment, in the shape the adapter reads.
//
// THE UPDATES BELOW ARE REPRESENTATIVE, NOT A REPLAY. They carry the structure
// of what a Ledger API `update-by-id` returns, and stand-in identifiers —
// `pkg`, `iface`, `holding-iface`, `offer-contract`, `payee-holding` — chosen
// so the sample reads. A real network's package ids are 64 hex characters and
// are its own; never copy these into a trust policy.
//
// The genuine captures, with real package ids and real interface views, are not
// part of the published packages, so a sample that installs them cannot read
// them.
//
// No network is touched either way.
import {
recordFromUpdate,
resolveOffer,
toSettlementObservation,
reconcile,
payloadDigest,
} from '@payvol/core/draft02';
// The request that was paid, as it was on the wire.
const payload = {
version: '0.2-draft',
type: 'transfer',
executionScope: { network: 'canton-localnet', synchronizerPolicy: 'any' },
payee: {
party: 'payvol-030-payee::1220545378cb834f99674270bed3479adfbf38378d3a35225214f07ad18a2e557bdc',
},
requestedReceive: {
instrument: {
admin: 'DSO::12200eed16a671e197699ed457f66917dd732a38d489e5b059a0250086ae4d2a37ee',
id: 'Amulet',
},
amount: '1.5',
destination: {
owner:
'payvol-030-payee::1220545378cb834f99674270bed3479adfbf38378d3a35225214f07ad18a2e557bdc',
provider: 'DSO::12200eed16a671e197699ed457f66917dd732a38d489e5b059a0250086ae4d2a37ee',
id: '',
},
},
expiry: '2026-09-08T13:09:53.927Z',
};
const digest = payloadDigest(payload);
// The offer, reduced to the fields the adapter reads. Stand-in identifiers; see
// the note at the top for where the genuine captures live.
const rawResponse = {
update: {
Transaction: {
value: {
updateId: '1220a157517ffc8ed0d02baea8cfaef1d38ce7a17e5b1e37901fa9897b008ce269a7',
effectiveAt: '2026-09-08T11:09:53.927Z',
events: [
{
CreatedEvent: {
templateId: 'pkg:Splice.Api.Token.TransferInstructionV1:TransferInstruction',
contractId: 'offer-contract',
createArgument: {
transfer: {
receiver:
'payvol-030-payee::1220545378cb834f99674270bed3479adfbf38378d3a35225214f07ad18a2e557bdc',
amount: '1.5',
instrumentId: {
admin:
'DSO::12200eed16a671e197699ed457f66917dd732a38d489e5b059a0250086ae4d2a37ee',
id: 'Amulet',
},
meta: {
values: {
'payvol.xyz/request-digest': digest,
'payvol.xyz/profile': '0.2-draft',
},
},
},
},
},
},
],
},
},
},
};
// The acceptance the recipient signed, which is what makes the offer a payment.
const acceptResponse = {
update: {
Transaction: {
value: {
updateId: '1220dca9433d631dbe0d67de51d4f8815aaee06e1c670003babcef3c0c9868280661',
effectiveAt: '2026-09-08T11:09:55.104Z',
events: [
{
ExercisedEvent: {
contractId: 'offer-contract',
consuming: true,
choice: 'TransferInstruction_Accept',
templateId: 'pkg:Splice.Api.Token.TransferInstructionV1:TransferInstruction',
interfaceId: 'iface:Splice.Api.Token.TransferInstructionV1:TransferInstruction',
exerciseResult: {
output: {
tag: 'TransferInstructionResult_Completed',
value: { receiverHoldingCids: ['payee-holding'] },
},
},
},
},
{
CreatedEvent: {
templateId: 'pkg:Splice.Amulet:Amulet',
contractId: 'payee-holding',
// The token standard's own view of the credited holding. The
// Ledger API returns it only when the query asks for the
// interface; a wildcard filter alone gives interfaceViews: [].
interfaceViews: [
{
interfaceId: 'holding-iface:Splice.Api.Token.HoldingV1:Holding',
viewStatus: { code: 0, message: '', details: [] },
viewValue: {
owner: payload.payee.party,
instrumentId: payload.requestedReceive.instrument,
amount: '1.5',
lock: null,
},
},
],
},
},
],
},
},
},
};
// Which templates and choices this deployment trusts. Payvol ships no default:
// the package ids a network runs are its own configuration, not a library fact.
const trust = {
offerTemplates: [
{
packageId: 'pkg',
module: 'Splice.Api.Token.TransferInstructionV1',
entity: 'TransferInstruction',
},
],
offerInterfaces: [
{
packageId: 'iface',
module: 'Splice.Api.Token.TransferInstructionV1',
entity: 'TransferInstruction',
},
],
choices: {
accept: ['TransferInstruction_Accept'],
reject: ['TransferInstruction_Reject'],
withdraw: [],
},
holdingInterfaces: [
{ packageId: 'holding-iface', module: 'Splice.Api.Token.HoldingV1', entity: 'Holding' },
],
completedResultTag: 'TransferInstructionResult_Completed',
};
const input = {
observationId: 'localnet-transfer',
observedAt: '2026-09-08T11:10:02.000Z',
metadataKeys: { digest: 'payvol.xyz/request-digest', profile: 'payvol.xyz/profile' },
trust,
};
const observed = recordFromUpdate(rawResponse, input);
console.log('adapter read ', 'ok =', observed.ok);
if (!observed.ok) {
console.log('missing ', observed.missing.join(', '));
} else {
console.log('digest match ', observed.record.digest === digest);
console.log('amount ', observed.record.amount);
console.log('receiver ok ', observed.record.receiver === payload.payee.party);
// Creating a TransferInstruction is an OFFER. It is not yet a payment.
console.log('lifecycle ', observed.record.lifecycle);
const settled = resolveOffer(observed.record, acceptResponse, input);
console.log('after accept ', settled.ok ? settled.record.lifecycle : settled.missing.join(', '));
if (!settled.ok) throw new Error('the acceptance did not resolve');
console.log('settled at ', settled.record.effectiveAt);
// What ARRIVED, read from the credited holding's own view.
console.log('credited ', settled.record.settlement.credited[0].amount);
console.log('credited to ', settled.record.settlement.credited[0].owner === payload.payee.party);
const record = reconcile(payload, digest, [toSettlementObservation(settled.record)], {
allowPartial: false,
evidenceWindow: { from: '2026-09-08T00:00:00Z', to: '2026-09-09T00:00:00Z' },
});
console.log('amount ', record.status.amount);
console.log('timing ', record.status.timing);
console.log('instrument ', record.status.instrument);
console.log('receiver ', record.status.receiver);
console.log('flags ', record.flags.length === 0 ? '(none)' : record.flags.join(','));
console.log('readiness ', record.closureReadiness);
} adapter read ok = true
digest match true
amount 1.5
receiver ok true
lifecycle offered
after accept settled
settled at 2026-09-08T11:09:55.104Z
credited 1.5
credited to true
amount exact
timing on-time
instrument match
receiver match
flags (none)
readiness eligible-for-closure
Output from @payvol/core version 0.4.0 and its sibling
packages, installed from npm, on 2026-10-18.
eligible-for-closure here means every axis agreed and no flag was raised. It is
the strongest verdict the reconciler gives, and it is still a description of evidence rather
than a decision to close anything.
Running it yourself, and what to expect
The scripts are in examples/localnet-e2e/.
# from a clean clone
pnpm install
cd examples/localnet-e2e
node run.mjs The payment flow is verified. The one-command cold start is not. node run.mjs
does not complete on every machine: on one, splice exits before its health
check, so the payment loop never begins. It is not memory and not the restart
policy; its health check fails connecting to the validator API.
Requirements when it does run: Docker with the compose plugin, the Canton and Splice
0.6.11 images, several GB of disk on first pull, and minutes of boot. Everything
binds to 127.0.0.1, the compose project and volumes are namespaced to this
example, and run.mjs tears the network down with its volumes when it finishes.