Asentum

Build on Asentum

Fast Confirmations

Show a transaction as soon as it is in a block, then lock it in at finality · Estimated read time: 8 minutes

Accurate as of node 0.8.0.

TL;DR

A transaction is in a block about one block after you send it (roughly 2.5 seconds) and final a few blocks later (roughly 7 to 10 seconds). Your app can show success at the first point instead of the second. That is how Auras shows a swap as filled within one block.

  • Poll GET /receipts/<txHash> about once a second. It answers from the newest block by default.
  • A receipt with finalized: false means "in a block". Show it as done.
  • When finalized turns true, it can no longer change. Lock it in.
  • Read state with POST /view (it reads the tip by default) so balances update in the same moment.

Copy the waitForInclusion helper if you just want the code.

How a transaction is confirmed

Asentum uses two layers (see Consensus & Validators):

  • Aura block production. Slots are 2 seconds and a new block lands about every 2.5 seconds. The newest block is the tip.
  • GRANDPA finality. Validators vote blocks final. The finalized height trails the tip by about 3 blocks.

You can see both heights at any moment:

$ curl -s https://testnet.asentum.com/v3/heads
{"finalized":"570907","tip":"570911"}

So a transaction moves through three states:

StateWhat the RPC saysWhat it means
pendingGET /receipts answers "receipt not found"Accepted into the mempool, not in a block yet.
in blockReceipt with finalized: falseExecuted in the newest block on the best chain. Its effects are visible at the tip. About 2.5 s after sending.
finalReceipt with finalized: trueGRANDPA finalized its block. It never changes after this. About 7 to 10 s after sending.

What can happen before finality

Sometimes two blocks are produced for the same height. Only one of them can become final. If your transaction was in the block that loses, its receipt disappears for a moment, the transaction goes back to the mempool, and it is normally included again in a later block. The new receipt can have a different blockNumber and blockHash.

This is rare. Since node 0.8.0 the chain averages about 1.04 blocks per height, so almost every in-block transaction stays in the block it was first seen in.

Because the transaction is executed again in the new block, its result can also differ. A swap re-run one block later sees different reserves, so the amount out can change, and in an unlucky case your slippage limit can make it fail. For that reason, treat the in-block result as very likely, and the final result as the truth.

Recommended UX

  1. Pending. Show the transaction the moment the wallet returns its hash, with a link to the explorer.
  2. In block. Show success optimistically: the trade is filled, the message is sent, the balance moved. Auras labels this Filled, in block.
  3. Final. Mark it confirmed. This is the point of no return.
●

In block is not irreversible

For anything high value, wait for finalized: true before you act on it off chain: releasing goods, crediting an exchange deposit, paying out, unlocking a reward. Showing a green tick early is fine. Acting on it early is not.

Submit a transaction

In a dapp the wallet signs and submits for you. With @asentum/connect, client.call(...), client.transfer(...) and client.deploy(...) return the transaction hash once the node has accepted it. With the SDK, contract.send(...) returns an object with hash and wait().

Under the hood both post the signed transaction (as hex) to POST /tx:

POST /tx
{"rawTx":"0x..."}

{"accepted":true,"txHash":"0x6c91b3ce...d4d5cd"}
{"accepted":false,"reason":"..."}

accepted: true only means the node checked the transaction and put it in its mempool. It is not in a block yet and it has not run yet, so it can still fail. Start polling the receipt as soon as you have the hash.

Read the receipt

GET /receipts/<txHash> serves receipts from the newest block by default. Until the transaction is in a block (or after its block lost to a sibling) it answers HTTP 200 with:

{"error":"receipt not found","txHash":"0x6c91b3ce...d4d5cd"}

A swap on Auras, one block after it was included (events trimmed):

{
  "txHash": "0x6c91b3cec4c0cf9da739221d2e8c1fac73df528c1225c918ce6135b02dd4d5cd",
  "blockNumber": "570913",
  "txIndex": 0,
  "sender": "0xb33d1f4c1009469a14779a0ac7a32886a1be79e6",
  "recipient": "0x63f640a9b98b6f28e06cb5cc0fce5d72b6c0eabd",
  "kind": "call",
  "success": true,
  "gasUsed": "75257",
  "events": [{ "name": "Swap", "data": { ... } }],
  "returnValue": "132515600790091497334",
  "finalized": false,
  "confirmations": "3",
  "blockHash": "0x0db45db9ac780355c622cf258710d48a730bfe7ebc86964e8f92771b0e23dbd1",
  "auraHash": "0x56466f1f2bc5610f0a3bb26f8de8a7150b82305fd1e3481586a5f5e2b0c1a9bc"
}

The same receipt a few seconds later has "finalized": true, a higher confirmations, and no auraHash.

FieldMeaning
finalizedfalse: in a block on the best chain, not final yet. true: final.
confirmationstip height minus blockNumber, plus 1 (a decimal string). 1 means it is in the newest block.
blockNumberHeight of the block it is in (decimal string).
blockHashHash of that block. Compare it between polls to spot a move to another block.
auraHashOnly while not final: the id of the pending block it is in.
successfalse means the transaction ran and failed.
errorOnly on a failed transaction: why it failed (for example a revert message).

sender and recipient come back as 0x hex. Convert them to ase1 before you show them to a user, for example with toAse1 from @asentum/connect.

Add ?tip=0 to get finalized receipts only. The pending receipt above answered receipt not found with ?tip=0 until its block was final.

Pending blocks and heads

Block reads are finalized only unless you ask for the tip. GET /block/<n> for a height above the finalized head answers {"error":"not found"}, and GET /block/<n>/receipts answers an empty list. Add ?tip=1 to get the pending block on the best chain:

$ curl -s "https://testnet.asentum.com/block/570913?tip=1"
{"header":{...},"transactions":[...],"receipts":[...],
 "finalized":false,"confirmations":"1","auraHash":"0x56466f1f...b0c1a9bc"}

$ curl -s "https://testnet.asentum.com/block/570913/receipts?tip=1"
{"blockNumber":"570913","receipts":[...],"finalized":false,"confirmations":"1","auraHash":"0x56466f1f...b0c1a9bc"}

A finalized block comes back with finalized: true either way. GET /v3/heads gives you both heights in one small response, which is the cheapest way to drive a block counter or decide which heights to fetch.

Read state at the tip

POST /view takes an optional blockTag. "tip" is the default ("latest" and "pending" mean the same). "finalized" or "safe" read the finalized state. The response says which block it read:

$ curl -s -X POST https://testnet.asentum.com/view -H 'content-type: application/json' \
  -d '{"contract":"ase1v0myp2de3dhj3crvkhxqlnjaw2mvp64a9efkpl","method":"getPool","args":["3"],"blockTag":"tip"}'
{"ok":true,"returnValue":"{\"id\":\"3\",\"reserve0\":\"1044284524303410925968734\",...}",
 "events":[],"gasUsed":"21800","blockNumber":"570931","finalized":false}

# same call with "blockTag":"finalized"
{"ok":true,"returnValue":"...","events":[],"gasUsed":"21800","blockNumber":"570929","finalized":true}

Any other value is rejected with invalid "blockTag". GET /balance/<ase1 address> also reads the tip.

JSON-RPC, ethers and viem

The Ethereum-style JSON-RPC on POST / follows the same rules, with quantities in hex as Ethereum does:

MethodBehaviour
eth_getTransactionReceiptReturns in-block receipts with two extra fields, finalized (bool) and confirmations (hex). Pass "finalized" as a second param for final receipts only. null until included.
eth_getBlockByNumber"tip" (non-standard) is the newest block, "finalized" and "safe" the finalized head. The result has a finalized flag.
eth_call, eth_getBalance"finalized" and "safe" read finalized state. Every other tag, including "latest", reads the tip.
eth_blockNumber, "latest" blocksThe FINALIZED height on the public RPC, not the tip.

That last row matters. ethers tx.wait() and viem waitForTransactionReceipt count confirmations against eth_blockNumber, so on Asentum they effectively wait for finality. To get in-block speed, poll the receipt yourself and read the extra fields:

// ethers v6
const r = await provider.send('eth_getTransactionReceipt', [txHash]);
if (r) {
  const ok = r.status === '0x1';
  const final = r.finalized;            // true once the block is final
  const confs = Number(r.confirmations); // hex quantity
}

// viem
const r = await client.request({ method: 'eth_getTransactionReceipt', params: [txHash] });

The REST /receipts route is simpler (decoded events, decimal strings) and is what the helper below uses.

waitForInclusion helper

Plain JavaScript, no dependencies, works in the browser and in Node 18+. It is the same approach Auras uses in production: poll once a second, keep polling on network errors and on "not found", fail fast on success: false, and go back to pending if a receipt it has seen disappears.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const hidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden';

// Polls GET /receipts/<txHash> about once a second.
//   onIncluded(receipt)  the tx is in a block (show success now)
//   onPending()          a block it was in lost to a sibling; it is back in the mempool
//   onFinal(receipt)     the block is final (point of no return)
// Resolves with the final receipt. Rejects if the tx failed on chain
// (err.receipt is set) or nothing final was seen within timeoutMs
// (err.receipt is unset: the tx may still go through).
export async function waitForInclusion(txHash, {
  rpc = 'https://testnet.asentum.com',
  onIncluded,
  onPending,
  onFinal,
  timeoutMs = 120_000,
  signal,
} = {}) {
  const deadline = Date.now() + timeoutMs;
  let seenIn = null; // blockHash of the block we last reported
  let delay = 1000;

  while (Date.now() < deadline) {
    if (signal?.aborted) throw new Error('aborted');
    if (hidden()) { await sleep(1000); continue; } // the tx keeps going without us

    let r = null;
    try {
      const res = await fetch(`${rpc}/receipts/${txHash}`, { cache: 'no-store' });
      if (res.status === 429 || res.status >= 500) {
        const retryAfter = Number(res.headers.get('retry-after'));
        delay = Math.min(10_000, retryAfter > 0 ? retryAfter * 1000 : delay * 2);
        await sleep(delay);
        continue;
      }
      const j = await res.json();
      // Not included yet: HTTP 200 with {"error":"receipt not found"}.
      // A failed tx also has "error", but it has a blockNumber too.
      if (j && j.blockNumber != null) r = j;
      delay = 1000;
    } catch {
      // network blip: keep polling
    }

    if (r) {
      if (r.success === false) {
        const err = new Error(r.error ? `failed on chain: ${r.error}` : 'failed on chain');
        err.receipt = r;
        throw err;
      }
      if (seenIn !== r.blockHash) { seenIn = r.blockHash; onIncluded?.(r); }
      if (r.finalized) { onFinal?.(r); return r; }
    } else if (seenIn) {
      // Seen in a block before, gone now: that block lost at its height.
      seenIn = null;
      onPending?.();
    }
    await sleep(delay);
  }
  throw new Error(`no final receipt after ${timeoutMs / 1000}s, check ${txHash} on the explorer before retrying`);
}

Using it with a wallet from @asentum/connect:

import { useAsentum } from '@asentum/connect';
import { waitForInclusion } from './waitForInclusion';

const client = useAsentum();

async function swap() {
  // The wallet signs and submits, then returns the tx hash.
  const txHash = await client.call(AMM, 'swapExactIn', [poolId, tokenIn, amountIn, minOut], value);
  setTx(txHash, 'pending');
  try {
    await waitForInclusion(txHash, {
      onIncluded: (r) => { setTx(txHash, 'in-block'); refreshAfter(r); },
      onPending: () => setTx(txHash, 'pending'),
      onFinal: () => setTx(txHash, 'final'),
    });
  } catch (e) {
    setTx(txHash, e.receipt ? 'failed' : 'still-pending');
  }
}

// One entry per tx hash, so a repeated callback never adds a second row.
function setTx(hash, status) {
  setTxs((cur) => [{ hash, status }, ...cur.filter((t) => t.hash !== hash)]);
}

A timeout is not a failure. The transaction may still land, so say "still pending" and link the explorer instead of inviting the user to send it again.

Update the UI after inclusion

The tip state already contains your transaction, so read it right away in onIncluded. Balances, pool reserves and your own lists then change in the same moment the status flips to "in block".

const RPC = 'https://testnet.asentum.com';

// Read contract state at the tip, the same state your tx was applied to.
async function viewAtTip(contract, method, args = []) {
  const res = await fetch(`${RPC}/view`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ contract, method, args, blockTag: 'tip' }),
  });
  const j = await res.json();
  if (!j.ok) throw new Error(j.reason);
  return { value: j.returnValue, blockNumber: BigInt(j.blockNumber), finalized: j.finalized };
}

// Called from onIncluded: refresh the numbers the tx changed.
async function refreshAfter(receipt) {
  const pool = await viewAtTip('ase1v0myp2de3dhj3crvkhxqlnjaw2mvp64a9efkpl', 'getPool', ['3']);
  // Behind a load balancer the read can come from a node one block behind.
  // Only trust it if it already includes the tx's block.
  if (pool.blockNumber < BigInt(receipt.blockNumber)) return setTimeout(() => refreshAfter(receipt), 1000);
  showPool(JSON.parse(pool.value));
  const bal = await (await fetch(`${RPC}/balance/${myAse1}`, { cache: 'no-store' })).json();
  showBalance(bal.balance); // /balance also reads the tip
}

Address arguments inside args are passed to the contract exactly as you send them, and contracts store accounts as 0x hex (that is what msg.sender is). With raw /view, convert ase1 arguments to hex first (ase1ArgsToHex is exported by both libraries). The contract field itself accepts ase1. client.view in @asentum/connect and viewCall in the SDK do this for you and also read the tip.

SDK and @asentum/connect

Both libraries resolve at inclusion by default, not at finality, because they poll /receipts and return the first receipt they get:

  • @asentum/connect: await client.waitReceipt(txHash) polls every 1.5 s for up to 60 s.
  • SDK 0.2.0: await tx.wait() polls every 1 s for up to 60 s and resolves at the first receipt.

The SDK has the whole helper built in. tx.wait({ until: 'final' }) and client.waitForReceipt(hash, options) take onIncluded, onReorg, onFinal, timeoutMs and an AbortSignal, and back off on 429 (see the SDK reference):

const tx = await amm.connect(wallet).send('swapExactIn', args, { valueWei });
await tx.wait({
  until: 'final',
  onIncluded: (r) => showFilled(r),   // in block
  onReorg: () => showPending(),       // its block lost; it lands again
  onFinal: (r) => markFinal(r),       // point of no return
});

With @asentum/connect, the receipt carries finalized and confirmations (the TypeScript type may not list them yet), but it does not report a receipt that later moves or the moment of finality. Use waitForInclusion above for that, or check the flag:

const receipt = await client.waitReceipt(txHash);
if (!receipt.success) throw new Error(receipt.error || 'failed on chain');
showFilled(receipt);                                   // in block
if (receipt.finalized === false) {
  await waitForInclusion(txHash, { onFinal: markFinal }); // lock in at finality
}

Good practice

  • Poll once a second. Blocks are about 2.5 s apart, so faster polling buys nothing. Receipt and block reads share a per-IP budget on each node (a burst, then about 4 a second), and the public gateway has its own limits.
  • Back off on HTTP 429. Honour Retry-After when it is there, otherwise double the delay up to about 10 s.
  • Pause when the tab is hidden. Check document.visibilityState. The transaction does not need you to watch it.
  • Link the explorer. https://explorer.asentum.com/tx/<txHash> from the moment you have a hash.
  • Dedupe by tx hash. Key optimistic rows and toasts by hash, so a callback that fires twice, or a tx that moves block, updates one row instead of adding a second.
  • Reconcile at finality. If you showed an estimated amount, replace it with the value from the final receipt or its events.
  • Finality is the point of no return. Anything that leaves the chain (shipping, payouts, crediting a deposit) waits for finalized: true.
  • Use one RPC for a flow. Submit, poll and read through the same endpoint where you can. If you run your own, see Run Your Own RPC.

Indexers

eth_getLogs serves finalized blocks only, so an indexer built on logs alone runs a few blocks behind. To show activity at in-block speed, add a second pass over the live window, the way the Auras trade indexer does:

  1. Read /v3/heads. For each height from finalized + 1 to the tip, fetch /block/<n>/receipts?tip=1.
  2. Write what you find with an finalized = false flag and the block hash, keyed by tx hash and event index.
  3. When a height comes back with a different block hash, delete the unfinalized rows from the old one and write the new ones.
  4. Your normal finalized pass rewrites the same rows with finalized = true and the canonical values. Never overwrite a finalized row.
  5. After each finalized pass, delete unfinalized rows at or below the finalized height. Anything left there came from a block that lost.

Serve the flag in your API so frontends can style unconfirmed rows, and keep totals that matter (balances owed, payouts, leaderboards) on finalized rows only.

Read next