Yamale docs ← back to the site

Send a payment

How money moves between institutions on Yamale, and how the statement entry that records it is kept trustworthy.

You need: a running chain and an account with some YML. Run a local chain gets you both. Every command below was run against a real node.

You will end with: two approved institutions, a registered customer, a settled payment, and the statement entry it produced.


The shape of it

A payment is an ISO 20022 pacs.008 credit transfer: a real transfer of funds, plus the information that makes it reconcilable — an end-to-end reference, a purpose code, and remittance details. It settles in one block, and it leaves a queryable record that plays the part of a camt.053 statement entry.

Three parties appear on every payment:

Both participants must be approved by governance. And — this is the part that makes the record worth anything — the debtor must actually bank with the instructing participant it names.

1. Both institutions apply

blockchaind tx paymsg apply-participant 00000011 "Bank A" \
  --from bank-a --chain-id yamale-testnet-1 --keyring-backend test --fees 500uyml --yes

The first argument is the participant code, the ISPB-equivalent identifier your institution is known by. Applications sit pending:

blockchaind query paymsg list-participant-application

2. Governance approves them

MsgApproveParticipant is signed only by the governance module account, so it goes through a proposal — one per institution:

{
  "messages": [{
    "@type": "/blockchain.paymsg.v1.MsgApproveParticipant",
    "authority": "yml10d07y265gmmuvt4z0w9aw880jnsr700jrghjur",
    "participant": "yml1rhd9xfc0a6nhetjqqdep0u2r3p30qj8c9cuk0v",
    "approve": true
  }],
  "metadata": "",
  "deposit": "10000000uyml",
  "title": "Admit Bank A as a payment participant",
  "summary": "Licensed institution; approving lets it instruct and receive payments."
}

Get the authority from blockchaind query auth module-account gov, and submit with --gas 400000 — the 200,000 default is not enough for a proposal carrying a message and fails with code: 11.

blockchaind query paymsg list-approved-participant
approved_participant:
- code: "00000011"
  name: Bank A
  participant: yml1rhd9xfc0a6nhetjqqdep0u2r3p30qj8c9cuk0v
- code: "00000012"
  name: Bank B
  participant: yml1exg54yeg95hqt7u2gvmmzxey04qslusupk2v4q

3. The bank registers its customer

An approved institution is not yet entitled to appear on anybody's payment. It has to claim the accounts it acts for:

blockchaind tx paymsg register-customer <customer-address> true \
  --from bank-a --chain-id yamale-testnet-1 --keyring-backend test --fees 500uyml --yes

Only the participant may sign this, and an account can bank with one participant at a time — a second institution trying to claim it is refused. Passing false ends the relationship, which the participant can do without governance being involved.

Why this step exists. Without it, naming a participant on a payment was an unverified claim. Any account could file an instruction attributing it to two institutions that had never seen it, and those institutions would find payments they never processed recorded against their name — in the ledger their customers reconcile against. Nothing was stealable, because the transfer always came from the signer's own balance. What was broken was the meaning of every record in the module.

Skipping it produces exactly that refusal:

code 1107: ... does not bank with ..., so that participant may not be named as
instructing this payment

4. Send

blockchaind tx paymsg send-payment \
  "INV-2026-0001" \
  <instructing-participant> <instructed-participant> \
  <creditor> uyml 250000000 GDSV "Invoice 2026-0001" \
  --from customer --chain-id yamale-testnet-1 --keyring-backend test --fees 500uyml --yes

That is 250 YML with purpose code GDSV (goods and services). As always, check what the transaction actually did rather than trusting the broadcast:

blockchaind query tx <txhash>

5. Read the statement entry

blockchaind query paymsg get-payment-record <instructing-participant> "INV-2026-0001"
payment_record:
  amount: "250000000"
  block_height: "642"
  creditor: yml1t0nf0crxydpw3vvzvk27chxmuyfzma7v6ca8l2
  debtor: yml1t0nf0crxydpw3vvzvk27chxmuyfzma7v6ca8l2
  denom: uyml
  end_to_end_id: INV-2026-0001
  instructed_participant: yml1exg54yeg95hqt7u2gvmmzxey04qslusupk2v4q
  instructing_participant: yml1rhd9xfc0a6nhetjqqdep0u2r3p30qj8c9cuk0v
  purpose_code: GDSV
  remittance_information: Invoice 2026-0001

The query takes the participant as well as the reference, because that is how the record is keyed. End-to-end ids are unique per instructing party in ISO 20022, not globally — so two banks can both use INV-2026-0001 without colliding, and neither can take a reference the other intends to use. Verified: the same id was sent successfully under both banks in the run above.

Paying the fee for your customers

A customer who holds naira and no YML cannot send anything. Network fees are payable in YML, so their balance is stuck behind a currency they never asked for:

spendable balance 0uyml is smaller than 200uyml: insufficient funds

That is the wrong shape for a payments rail. In ISO 20022 the institution bears the cost of processing, not the customer, and this chain can work the same way — a participant grants its customers a fee allowance:

blockchaind tx feegrant grant <participant> <customer>   --spend-limit 1000000uyml   --expiration 2026-12-31T23:59:59Z   --from participant

The customer then transacts normally, naming the sponsor:

blockchaind tx bank send <customer> <recipient> 1000000ungn   --from customer --fee-granter <participant>

Verified on a live chain: a customer holding 500 NGN and zero YML was refused, then granted an allowance, then moved the money — and their YML balance was still exactly zero afterwards. The bank paid.

The allowance is a budget, not a blank cheque. It is consumed as it is spent (1,000,000 → 999,800 uyml after one 200 uyml fee), it can carry an expiry, and revoking it takes effect immediately:

blockchaind tx feegrant revoke <participant> <customer> --from participant

The chain's own error when a grant is missing renders the customer's address as raw bytes, which tells a support desk nothing. The client SDK translates it into "No fee allowance — this transaction asked another account to pay its network fee, but that account has not granted an allowance", so build against the SDK rather than parsing raw logs.

From an interface rather than the CLI, the SDK builds the same messages:

import { grantFeeAllowance, revokeFeeAllowance } from '@yamale/chain';

await signer.submit([
  grantFeeAllowance({
    granter: bank,
    grantee: customer,
    spendLimit: [{ denom: 'uyml', amount: '1000000' }],
    expiresAt: new Date('2026-12-31T23:59:59Z'),
  }),
]);

Both were signed from TypeScript against a live chain, and a customer with no grant comes back as succeeded: false carrying "No YML for the network fee" — not as a thrown exception, which is what CosmJS does on its own for anything rejected before it reaches a block.

Grants are per-customer, which is deliberate: an institution sponsoring a thousand customers issues a thousand grants and can revoke any one of them without touching the rest.

Field limits

Taken from ISO 20022 rather than invented, because they are what every system on the other side of these payments already enforces:

Field Limit
end_to_end_id 35 characters Max35Text; also a store key
purpose_code 4 characters ExternalPurpose1Code
remittance_information 140 characters Max140Text, unstructured

Exceeding one is refused with code 1108, naming the field and both lengths.


Things worth knowing

The transfer is real. This is not a message about a payment that settles elsewhere — SendPayment moves the funds through x/bank in the same transaction. If the debtor cannot cover it, nothing is recorded.

The purpose code and remittance information are first-class. They are the reason this module exists rather than using a plain transfer with a memo: they are the fields reconciliation actually needs, and they are queryable.

A participant can pay from its own balance. It is its own instructing agent, so no registration is needed for that case.

Full reference: x/paymsg — every message, query, parameter and error code, generated from the source.