Animica Pay docs 2.00% per successful payment

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.

StateTerminalMay move to
CREATEDnoAWAITING_PAYMENT, EXPIRED, FAILED
AWAITING_PAYMENTnoPAYMENT_DETECTED, EXPIRED, FAILED, UNDERPAID, OVERPAID
PAYMENT_DETECTEDnoCONFIRMING, FAILED, UNDERPAID, OVERPAID, AWAITING_PAYMENT
CONFIRMINGnoPAID, FAILED, UNDERPAID, OVERPAID, AWAITING_PAYMENT
PAIDyesREFUNDED, PARTIALLY_REFUNDED, AWAITING_PAYMENT
UNDERPAIDyesPAYMENT_DETECTED, CONFIRMING, PAID, EXPIRED, REFUNDED
OVERPAIDyesPAID, REFUNDED, PARTIALLY_REFUNDED
EXPIREDyesPAYMENT_DETECTED
FAILEDyesAWAITING_PAYMENT
REFUNDEDyesnone
PARTIALLY_REFUNDEDyesREFUNDED

Reading that table:

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:

js
// 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:

text
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:

text
tx.to == intent.payment_address   AND   reference in tx.data

Two properties make this the right mechanism on this chain:

  1. data is 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.
  2. 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:

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.

text
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:

Where the money is, at each moment #

MomentCustomer fundsYour books
Intent createdwith the customernothing
Detected in a blockon chain, unconfirmednothing
Confirmingon chainnothing
PAIDat your address (merchant-direct)gross debited, 98% payable to you, 2% fee income, 2% receivable owed by you
Fee swept2% moved to the treasuryreceivable discharged

Read The splitting limitation for why the last two rows exist at all.