@asentum/sdk
TypeScript SDK for Asentum. Read the chain, sign with Dilithium3 (ML-DSA-65), send transactions, follow them from "in a block" to "final", and work with contracts and their events. Works in Node.js 18+ and modern browsers.
Current with node 0.8.1.
npm install @asentum/sdk
Quick start
import { AsentumClient, AsentumWallet, formatAse } from '@asentum/sdk';
const client = new AsentumClient('https://testnet.asentum.com');
// Reads need no keys
const { finalized, tip } = await client.getHeads();
const bal = await client.getBalance('ase1v0myp2de3dhj3crvkhxqlnjaw2mvp64a9efkpl');
console.log(formatAse(bal.balance), 'ASE at block', tip);
// A wallet signs locally; keys never leave your process
const wallet = AsentumWallet.fromSecretKey(client, SECRET_KEY_HEX, PUBLIC_KEY_HEX);
console.log(wallet.address); // ase1...
const tx = await wallet.sendTransfer({
to: 'ase1wtc569gfwk87hplaawr46uj4suwsx2wndgf8ue',
amountWei: 10n ** 18n, // 1 ASE
});
const receipt = await tx.wait(); // in a block, about 2.5 s
if (!receipt.success) throw new Error(receipt.error);
await tx.wait({ until: 'final' }); // final, a few blocks later
Test ASE comes from the airdrop dashboard (see "Get Test ASE" in the docs).
The node's /dev/faucet route is off on every public node.
Things to know about the chain
- Two heads. The tip is the newest block on the best chain. The
finalized height trails it by about 3 blocks and never changes.
getHeads()returns both. - Accepted is not included.
POST /txansweringaccepted: trueonly means the node put the transaction in its mempool. Wait for the receipt. - Receipts use
success, a boolean. There is nostatuson the REST receipt. A failed transaction hassuccess: falseand anerrorreason. - Receipts read the tip by default. A receipt with
finalized: falseis in a block; rarely that block loses to a sibling and the transaction lands again in a later block. Onlyfinalized: trueis permanent. - Addresses. The SDK accepts
ase1...or0x...everywhere and returns ase1 for every address it surfaces, also where the node answers with hex (receipts, participation, committee, governance). Contracts work with lowercase hex internally, so the SDK converts ase1 addresses inside contract arguments to hex before signing or viewing. Hashes and raw bytes stay 0x hex (they have no ase1 form), and so do addresses inside contract return values and event data: convert those withtoAse1before showing them. - Amounts are wei (1 ASE = 10^18 wei) as decimal strings in responses.
- Rate limits. The public gateway allows about 25 requests a second per
IP and answers 429 with an HTML page; nodes limit block and receipt reads
to a burst of 240, then 4 a second. The SDK retries 429 and 5xx with
backoff (honouring Retry-After when sent) before it throws
RateLimitError.
AsentumClient
const client = new AsentumClient(url?, {
retries: 3, // retries for 429 / 5xx / network errors
requestTimeoutMs: 30000,
fetch, // custom fetch, optional
headers: {}, // e.g. Authorization for a private RPC
});
Every method takes an optional last argument { signal?: AbortSignal, retries?: number }.
Chain and health
| Method | Route | Returns |
|---|---|---|
getHeads() | GET /v3/heads | { finalized: number, tip: number } |
getTipNumber() / getFinalizedNumber() | GET /v3/heads | number |
getBlockNumber() | GET /chain | finalized height, number |
getChainInfo() | GET /chain | chain id, finalized height, mempool size, header |
getChainId() | GET /chain | bigint (cached) |
getMetadata() | GET /metadata | EIP-3085 metadata |
getApiInfo() | GET /api | node identity |
getGenesis() | GET /genesis | genesis spec |
isHealthy() | GET /health | boolean |
isReady() | GET /ready | boolean (false on 503; note that node 0.8.1 reports ready even when stalled, so watch getHeads()) |
Accounts
| Method | Route | Returns |
|---|---|---|
getBalance(addr) | GET /balance/<addr> | { address, balance, nonce, pendingNonce } |
getNonce(addr, { pending? }) | GET /balance/<addr> | bigint, pendingNonce by default |
getAccount(addr) | GET /asentum/account/<addr> | { exists, balance, nonce, codeHash, storageRoot, height } |
getPublicKeyStatus(addr) | GET /public-key/<addr> | { recorded, canOmitKey, ... } |
Blocks
| Method | Route | Returns |
|---|---|---|
getBlock(n, { tip? }) | GET /block/<n>[?tip=1] | Block | null (finalized, and confirmations/auraHash on tip blocks) |
getBlockReceipts(n, { tip? }) | GET /block/<n>/receipts | { receipts, finalized?, available } |
getRawBlock(n) | GET /block-raw/<n> | SSZ hex or null |
getFinalizedStatus(h) | GET /v3/finalized?height= | { finalized, auraHash? } |
Transactions and receipts
| Method | Route | Returns |
|---|---|---|
getReceipt(hash, { finalizedOnly? }) | GET /receipts/<hash>[?tip=0] | Receipt | null (success, error?, finalized, confirmations, blockHash, auraHash?) |
waitForReceipt(hash, opts) | polls GET /receipts | Receipt (see below) |
getTransaction(hash) | eth_getTransactionByHash | EthTransaction | null |
submitRawTx(rawTx) | POST /tx | { accepted, reason?, txHash? } |
submitRawTxBatch(rawTxs) | POST /tx/batch | { accepted, rejected, total, results } |
Contracts
| Method | Route | Returns |
|---|---|---|
viewCall(contract, method, args, { blockTag? }) | POST /view | { ok, returnValue, reason?, blockNumber, finalized, ... } |
view(contract, method, args, { blockTag? }) | POST /view | the return value; throws ViewError |
getCode(addr) / getContractSource(addr) | eth_getCode | hex / source text |
getStorageAt(addr, key) | eth_getStorageAt | stored text or null |
getLogs({ address, event, fromBlock, toBlock }) | eth_getLogs | decoded Log[] (finalized blocks only; ranges over 10,000 blocks are split) |
blockTag is 'tip' (default; 'latest' and 'pending' mean the same) or
'finalized' ('safe'). There are no historical state reads.
Validators, consensus, governance
| Method | Route |
|---|---|
getValidators() | GET /validators (about 260 KB) |
getParticipation({ lookback }) | GET /participation |
getCommittee({ epoch }) | GET /v3/committee |
getLiveness() | GET /consensus/liveness |
getTombstones() | GET /slashing/tombstones |
getProposals() / getProposal(id) | GET /governance/proposals, /governance/proposal/<id> |
getGovernanceLocked(addr) / getGovernanceWithdrawable(addr) | GET /governance/locked/<addr>, /governance/withdrawable/<addr> |
getGovernanceLibraries() / getGovernanceLibrary(name) | GET /governance/libraries, /governance/library/<name> |
jsonRpc(method, params) | POST / (any JSON-RPC method) |
faucet() is deprecated: it calls /dev/faucet, which only works on a local
devnet that turned it on.
AsentumWallet
const wallet = AsentumWallet.create(client); // random key
const wallet2 = AsentumWallet.fromSeed(client, seed32); // deterministic
const wallet3 = AsentumWallet.fromSecretKey(client, skHex, pkHex);
wallet.address; // ase1...
wallet.addressHex; // 0x... lowercase, what contracts see as msg.sender
wallet.secretKeyHex; // back this up like a password
wallet.publicKeyHex;
await wallet.getBalance(); // bigint wei
await wallet.getNonce(); // bigint, the node's pendingNonce
Sending
await wallet.sendTransfer({ to, amountWei: 10n ** 18n });
await wallet.sendCall({ to: contract, method: 'swap', args: [...], valueWei: 0n });
const c = await wallet.deploy(source); // c.address is known right away
await c.waitForDeploy();
Every send returns { hash, nonce, from, contractAddress?, wait() } once the
node has accepted it, and throws TxRejectedError (with reason) when it
did not. A rejected transaction never reaches the chain.
Amounts. amountWei / valueWei are exact wei. The older amount /
value fields go through parseAse: a number or decimal string is ASE
('1.5' is 1.5 ASE), a bigint is wei, and an integer string longer than 18
digits is read as wei. sendTransferWei and sendCallWei take wei only.
Nonces. The wallet asks the node for pendingNonce and hands out
consecutive nonces itself, so back-to-back and concurrent sends from one
wallet get n, n+1, n+2. After a rejection it asks the node again
(wallet.resetNonce() does the same by hand). Pass nonce to override.
Submit and poll through the same RPC: pendingNonce is per node.
Batches. Sign first, then submit in one request:
const a = await wallet.signCall({ to, method: 'a' });
const b = await wallet.signCall({ to, method: 'b' });
const res = await wallet.sendBatch([a, b]); // POST /tx/batch
res.results[0].accepted; res.results[0].wait();
Key-less transactions. Once the chain has an account's public key on
record, its transactions can leave the key out and are about a third
smaller. AsentumWallet.create(client, { keyless: 'auto' }) switches to that
as soon as getPublicKeyStatus().canOmitKey is true. The default is 'off'.
Other overrides on every send: gasLimit (defaults: 21,000 for transfers,
2,000,000 for calls, at least 2,000,000 for deploys; contract calls need
at least 50,000), maxFeePerGas (default 1), signal.
Names (@usernames)
From node 0.9.0 and the names flag day (block 1,200,000 on chain 1423) an
account can own names like @milkie. A name points at an address; resolve
it when you build a transaction and show both on the confirm screen.
if (await client.namesActive()) {
const { address, name } = await client.resolveRecipient('@milkie'); // throws NameNotFoundError for a free name
const primary = await client.lookupAddress(wallet.address); // "milkie" or null
}
Before the flag day, and on nodes older than 0.9.0, every lookup throws
NamesNotActiveError.
Registering, transferring and records (0.3.1)
Every write is a signed call to the NameRegistry system contract
(NAME_REGISTRY_CONTRACT, 0x...0b). The wallet has a method for each:
await (await wallet.registerName('milkie')).wait(); // sends exactly nameFeeWei('milkie')
await wallet.transferName('milkie', 'ase1...'); // free; ase1 or 0x recipient
await wallet.setPrimaryName('milkie'); // the name apps show for this address
await wallet.clearPrimaryName();
await wallet.setNameRecord('milkie', 'x', '@asentum'); // keys: avatar, url, x, description; '' clears
| Method | Contract call | Notes |
|---|---|---|
registerName(name) | register(name) + fee | 500 / 100 / 50 ASE for 3 / 4 / 5 characters, 10 ASE for 6 or more. Half is burned, half goes to the treasury. The first name a wallet gets becomes its primary. At most 5 registrations per address per 43,200 blocks. |
transferName(name, to) | transfer(name, to) | Owner only, no fee. The name stops being the sender's primary and its records are cleared for the new owner. |
setPrimaryName(name) | setPrimary(name) | Owner only. |
clearPrimaryName() | clearPrimary() | Keeps the name. |
setNameRecord(name, key, value) | setRecord(name, key, value) | Owner only. avatar and url start with https://, http:// or ipfs://; x is an X handle. |
Each method takes the usual send options (gasLimit, nonce, signal, ...)
and returns a SentTx; call .wait() for the receipt. Invalid names throw
InvalidNameError before anything is signed.
Reads: client.getNameLookup(name) (owner, records, primary flag),
client.resolveName(name), client.lookupAddress(address), or a view call
such as client.viewCall(NAME_REGISTRY_CONTRACT, 'isAvailable', ['milkie']).
Building a names marketplace
The registry has no approve or operator method: only the owner can move a
name. A contract can own names, though, so a marketplace works as an escrow.
Inside a contract the registry sees the marketplace as msg.sender, and the
marketplace moves names with a cross-contract call,
E(registry).transfer(name, buyer).
List in this order so nobody else can claim a name in between:
- The seller calls
marketplace.prepare(name, price). The marketplace checksE(registry).get(name).owner === msg.senderand records the seller. - The seller calls
wallet.transferName(name, marketplaceAddress). marketplace.activate(name)checks the marketplace now owns the name.- A buyer calls
marketplace.buy(name)with the price. The marketplace pays the seller and callsE(registry).transfer(name, buyer). - To cancel, the seller calls
marketplace.cancel(name)and gets the name back through the same transfer.
Records are cleared on every transfer, so the buyer starts with a clean profile.
Signed messages
const proof = wallet.signMessage(challenge); // { address, publicKey, signature }
verifyMessage({ ...proof, message: challenge }); // true
The signed bytes are "Asentum Signed Message:\n" + byteLength(message) + "\n" + message (ML-DSA-65), the same as window.asentum.signMessage and the
Telegram wallet, so a server checks every wallet the same way.
AsentumContract
import { AsentumContract } from '@asentum/sdk';
const pool = new AsentumContract(client, 'ase1v0myp2de3dhj3crvkhxqlnjaw2mvp64a9efkpl');
await pool.view('getPool', ['3']); // reads the tip
await pool.view('getPool', ['3'], { blockTag: 'finalized' });
await pool.viewFull('getPool', ['3']); // value, gas, events, blockNumber
const tx = await pool.connect(wallet).send('swapExactIn', [...], { valueWei });
await pool.getEvents('Swap', { fromBlock: 570000, toBlock: 571000 });
await pool.getSource();
await pool.getAccount(); // codeHash = BLAKE3 of the source
Fast confirmations
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). Show success at the first point, act on it off chain only at the second.
import { TimeoutError } from '@asentum/sdk';
const tx = await contract.send('swapExactIn', args);
setStatus(tx.hash, 'pending');
try {
await tx.wait({
until: 'final',
onIncluded: (r) => setStatus(tx.hash, r.success ? 'in-block' : 'failed'),
onReorg: () => setStatus(tx.hash, 'pending'), // its block lost; it will land again
onFinal: (r) => setStatus(tx.hash, r.success ? 'final' : 'failed'),
timeoutMs: 120_000,
signal, // AbortSignal, optional
});
} catch (e) {
if (e instanceof TimeoutError) setStatus(tx.hash, 'still-pending'); // not a failure
else throw e;
}
client.waitForReceipt(hash, options) is the same for any hash. Options:
| Option | Default | Meaning |
|---|---|---|
until | 'included' | 'included': resolve at the first receipt. 'final': resolve once finalized: true. |
timeoutMs | 60,000 / 120,000 | Rejects with TimeoutError (lastReceipt set if it was in a block). |
pollIntervalMs | 1,000 | Blocks are about 2.5 s apart; faster buys nothing. |
onIncluded(r) | In a block. Fires again if a reorg moves it to another block. | |
onReorg(prev) | The block it was in lost to a sibling. | |
onFinal(r) | Final (with until: 'final'). | |
throwOnFailure | false | Reject with TxFailedError on success: false instead of resolving. |
signal | Stop waiting (AbortError). The transaction is not affected. | |
pauseWhenHidden | true | In a browser, no polling while the tab is hidden. |
The poller backs off on 429 and 5xx (Retry-After when sent, otherwise doubling up to 10 s) and keeps going through network blips.
tx.wait() with no argument behaves as in 0.1.x: it resolves at the first
receipt (in a block), including a failed one. tx.wait(ms) still takes a
timeout.
Errors
All errors the SDK throws on purpose extend AsentumError and carry a
stable code:
| Class | When |
|---|---|
TxRejectedError | POST /tx answered accepted: false (reason, isNonceError) |
TxFailedError | the receipt has success: false (with throwOnFailure) |
TimeoutError | waiting ran out (lastReceipt); the tx may still land |
AbortError | your AbortSignal fired |
ViewError | POST /view answered ok: false |
RpcError | HTTP 200 with an error body (bad address, bad hash, failed governance read) |
JsonRpcError | a JSON-RPC error object (rpcCode) |
RateLimitError | HTTP 429 after retries (retryAfterMs) |
HttpError | any other non-2xx (status, body) |
NetworkError | fetch failed or timed out |
"Not found" is not an error: getReceipt, getBlock, getTransaction,
getRawBlock and getProposal return null.
Helpers
import {
toAse1, toHexAddress, hexToAse1, ase1ToHex, isAse1, isHexAddress, isAddress, shortAse1,
ase1ArgsToHex, aseToWei, parseAse, formatAse, toWei, eventTopic, getContractAddress,
} from '@asentum/sdk';
toAse1('0x72f14d1509758feb87fdeb875d7255871d0329d3'); // 'ase1wtc569gfwk87hplaawr46uj4suwsx2wndgf8ue'
toHexAddress('ase1wtc569gfwk87hplaawr46uj4suwsx2wndgf8ue'); // '0x72f1...29d3'
ase1ArgsToHex(['ase1...', { owner: 'ase1...' }]); // every ase1 inside becomes 0x hex
aseToWei('1.5'); // 1500000000000000000n (always ASE)
formatAse(15n * 10n ** 17n); // '1.5'
eventTopic('Swap'); // topics[0] for eth_getLogs: BLAKE3 of the name
getContractAddress(wallet.address, 3n); // where a deploy with nonce 3 lands
Upgrading from 0.1.x
See CHANGELOG.md. The short list: wallet.address and
contract.address are ase1 now (addressHex has the hex), receipt
sender/recipient are ase1, getBlock returns null for a missing block,
getReceipt throws on transport errors instead of returning null, and the
faucet helpers are deprecated.
How it works
Transactions are SSZ encoded, hashed with BLAKE3, signed with ML-DSA-65
(Dilithium3) locally and submitted as hex to POST /tx. The transaction
hash is BLAKE3 of the signed bytes, so the SDK knows it before the node
answers. Reads use the node's REST routes, plus JSON-RPC for code, storage,
transactions and logs. See the Node API Reference
and Fast Confirmations.
Development
npm test # unit tests (mocked fetch)
npm run test:live # read-only smoke test against https://testnet.asentum.com
npm run typecheck
npm run build
License
Apache-2.0