Start here
Core concepts
The 11-state lifecycle, the ANMPAY1 reference, confirmations and reorgs.
Written for a developer who has never touched this chain. Four ideas do all the work: the intent lifecycle, the payment reference, confirmations, and reorgs.
The PaymentIntent lifecycle #
A PaymentIntent is the server-side record of one attempt to be paid. It owns the amount, the payment address, the reference, the fee policy that applied when it was created, and its state. There are 11 states and every transition is explicit; applying the same event twice is a no-op rather than an error, because the indexer, the webhook worker and the reconciler all legitimately observe the same on-chain fact more than once.
| State | Terminal | May move to |
|---|---|---|
CREATED | no | AWAITING_PAYMENT, EXPIRED, FAILED |
AWAITING_PAYMENT | no | PAYMENT_DETECTED, EXPIRED, FAILED, UNDERPAID, OVERPAID |
PAYMENT_DETECTED | no | CONFIRMING, FAILED, UNDERPAID, OVERPAID, AWAITING_PAYMENT |
CONFIRMING | no | PAID, FAILED, UNDERPAID, OVERPAID, AWAITING_PAYMENT |
PAID | yes | REFUNDED, PARTIALLY_REFUNDED, AWAITING_PAYMENT |
UNDERPAID | yes | PAYMENT_DETECTED, CONFIRMING, PAID, EXPIRED, REFUNDED |
OVERPAID | yes | PAID, REFUNDED, PARTIALLY_REFUNDED |
EXPIRED | yes | PAYMENT_DETECTED |
FAILED | yes | AWAITING_PAYMENT |
REFUNDED | yes | none |
PARTIALLY_REFUNDED | yes | REFUNDED |
Reading that table:
CREATED→AWAITING_PAYMENThappens as soon as the intent has an address and a reference.PAYMENT_DETECTEDmeans a matching transfer was found in a block. The mempool is never read, so a pending transaction cannot advance an intent by any code path.CONFIRMINGmeans depth is accumulating. Growing depth is progress, not a state change: it is persisted, and no event fires for each new block.UNDERPAIDandOVERPAIDnever auto-settle. They are a human decision — refund, ask for the difference, or accept — and the state machine refuses to promote them on depth alone.EXPIREDcan still move toPAYMENT_DETECTED, because a late payment is a fact whether or not your checkout window closed.PAIDcan move back toAWAITING_PAYMENT. That is the reorg path, and it is the reason PAID is not the end of your responsibility until depth is comfortable.
Why PAID needs a receipt and a depth #
On the Animica chain, inclusion is not execution. A transaction can appear in a block and still have failed: a historical executor bug swallowed an InsufficientBalance into a revert with stateRoot=0, and an exchange credited roughly 19.4M ANM of deposits that never happened. So the settlement call refuses to run without both:
// src/payments/state.js — the real guard, not a paraphrase
function markPaid(intent, { confirmations, requiredConfirmations, receiptOk }) {
if (receiptOk !== true) {
throw new TransitionError('refusing PAID without a successful receipt (inclusion != execution)');
}
const need = Number(requiredConfirmations ?? intent.required_confirmations);
if (Number(confirmations) < need) {
throw new TransitionError(`refusing PAID at ${confirmations}/${need} confirmations`);
}
// …
}
receiptOk comes from the receipt RPC, which reports an explicit success status. A missing receipt is not a failure and not a success — it is "not yet", and the intent stays where it is until the next poll.
The ANMPAY1 payment reference #
Every intent gets a reference that looks like this:
ANMPAY1:JBSWY3DPEHPK3PXPJBSWY3
It is ANMPAY1: followed by 26 base32 characters — 16 bytes of CSPRNG output. The customer’s wallet puts those ASCII bytes in the data field of the TRANSFER, and attribution is:
tx.to == intent.payment_address AND reference in tx.data
Two properties make this the right mechanism on this chain:
datais inside the signed bytes and inside the transaction id, so the payer commits to the reference. Nobody can re-label the payment afterwards, including us.- It is unique per intent, so two customers paying the same price in the same second stay distinguishable.
Why amount-matching is not identification #
Amount-matching — "someone sent 49.99, so order 19482 must be paid" — fails in ways that are not edge cases:
- Two customers checking out at the same price at the same time are indistinguishable, so one of them can be credited for the other’s money.
- An attacker who knows a common price can pay once and claim any order at that price.
- A partial or duplicated transfer has no owner, so it silently lands on whichever intent the query happened to sort first.
So in Animica Pay the amount decides under- or overpayment only, never identity, and one transaction can satisfy at most one intent — enforced by a unique index on the transaction hash, not by application care.
Confirmations #
A confirmation is a canonical block at or above the block containing your payment.
confirmations = min(canonicalHeight, height) - txBlockHeight + 1
The head RPC returns both height and canonicalHeight; we count against the lower of the two, so a node briefly ahead on a side branch cannot inflate the depth. The default requirement is 12, configurable per merchant, minimum 1. Higher is safer and slower; there is no depth at which reorg risk is exactly zero.
Reorgs #
Reorgs are not theoretical on this chain. It has had one-block forks, a 38,728-height fork wedge, and pinned checkpoints as a remedy. So the indexer treats reorgs as a first-class case:
- Before extending, the next block’s
parentHashmust equal the hash we recorded for our tip. A mismatch means the chain moved under us. - On mismatch we walk back to the last common ancestor and un-settle every intent whose evidence sat above it: the intent returns to
AWAITING_PAYMENTand its transaction binding is cleared. - The settlement ledger is append-only, so the accounting is reversed with contra-entries rather than edited. Both facts stay in the audit trail.
- A canonical height below our cursor is itself reorg evidence, and is handled the same way.
Where the money is, at each moment #
| Moment | Customer funds | Your books |
|---|---|---|
| Intent created | with the customer | nothing |
| Detected in a block | on chain, unconfirmed | nothing |
| Confirming | on chain | nothing |
| PAID | at your address (merchant-direct) | gross debited, 98% payable to you, 2% fee income, 2% receivable owed by you |
| Fee swept | 2% moved to the treasury | receivable discharged |
Read The splitting limitation for why the last two rows exist at all.