Calling Contracts
From an empty directory to a signed, mined contract call: SDK setup from a secret key, attaching an ACI to a deployed address, read calls, payable write calls, dry runs for quoting, gas limits, decoding the events a call emitted, and reading the revert reason when a call aborts.
Install
npm install @aeternity/aepp-sdk bignumber.jsThe code below targets @aeternity/aepp-sdk 14.
You also need the ACI files. Copy them from
superhero-agent-skill/contracts/ (opens in a new tab)
into a local ./contracts/ directory:
contracts/
AffiliationBondingCurveTokenSale.aci.json
BondingCurve.aci.json
BondingCurveExponential.aci.json
CommunityFactory.aci.json
FungibleTokenFull.aci.json
PollACI.json
RegistryWithEventsACI.json
Tipping_v3.aci.jsonA complete example
This script reads a token sale's price and then buys from it. It is runnable as written:
set AE_PRIVATE_KEY and pass a sale address.
#!/usr/bin/env node
import {
AeSdk, Node, MemoryAccount, Contract,
formatAmount, AE_AMOUNT_FORMATS,
} from '@aeternity/aepp-sdk';
import BigNumber from 'bignumber.js';
import fs from 'fs';
const NODE_URL = 'https://mainnet.aeternity.io';
const SLIPPAGE_PERCENT = 3;
// --- 1. Account from a secret key -----------------------------------------
// AE_PRIVATE_KEY is an sk_... secret key. Never hardcode it.
const secretKey = process.env.AE_PRIVATE_KEY;
if (!secretKey) throw new Error('AE_PRIVATE_KEY is not set');
const account = new MemoryAccount(secretKey);
// --- 2. Node connection ----------------------------------------------------
const aeSdk = new AeSdk({
nodes: [{ name: 'mainnet', instance: new Node(NODE_URL) }],
accounts: [account],
});
// --- 3. Contract instances from ACI + address ------------------------------
const saleAddress = process.argv[2];
const sale = await Contract.initialize({
aci: JSON.parse(fs.readFileSync('./contracts/AffiliationBondingCurveTokenSale.aci.json', 'utf8')),
address: saleAddress,
onAccount: aeSdk,
onNode: aeSdk.api,
});
// --- 4. Read calls (free, no transaction) ----------------------------------
const tokenAddress = await sale.token_contract().then((r) => r.decodedResult);
const token = await Contract.initialize({
aci: JSON.parse(fs.readFileSync('./contracts/FungibleTokenFull.aci.json', 'utf8')),
address: tokenAddress,
onAccount: aeSdk,
onNode: aeSdk.api,
});
const meta = await token.meta_info().then((r) => r.decodedResult);
// meta = { name, symbol, decimals } — community tokens use 18 decimals.
// `count` is always in the token's smallest unit. 5 whole tokens at 18 decimals:
const count = new BigNumber(5).shiftedBy(Number(meta.decimals)).toFixed(0);
// `price(count)` quotes the buy without sending a transaction.
const priceAettos = await sale.price(count).then((r) => r.decodedResult);
console.log('price:', formatAmount(priceAettos.toString(), {
denomination: AE_AMOUNT_FORMATS.AETTOS,
targetDenomination: AE_AMOUNT_FORMATS.AE,
}), 'AE');
// --- 5. A payable write call ----------------------------------------------
// `buy` is payable: the AE goes in the `amount` option, not in an argument.
// Send a slippage buffer — the price moves with every other buy in the block.
const amount = new BigNumber(priceAettos.toString())
.times(1 + SLIPPAGE_PERCENT / 100)
.toFixed(0, BigNumber.ROUND_CEIL);
const result = await sale.buy(count, { amount, omitUnknown: true });
console.log('tx:', result.hash);
console.log('events:', result.decodedEvents.map((e) => e.name));What each part is doing
| Step | Detail |
|---|---|
MemoryAccount(secretKey) | Holds the key in process memory and signs locally. Nothing is sent to a remote signer. |
new Node(url) | Points at a node's HTTP API. Mainnet https://mainnet.aeternity.io, testnet https://testnet.aeternity.io. |
Contract.initialize | Needs aci plus address. Pass onNode always, and onAccount only when you intend to sign. Without onAccount the instance can still do read calls. |
.decodedResult | Every call returns a result envelope. decodedResult is the value, already decoded against the ACI: integers come back as BigInt, records as plain objects, maps as Map. |
{ amount } | The AE attached to a payable call, in aettos (1 AE = 10^18 aettos). |
omitUnknown: true | Drops call-option keys the SDK does not recognise instead of throwing. Set it on every write. |
Arguments and types
The ACI drives argument encoding, so pass JavaScript values that match the Sophia type:
| Sophia type | Pass |
|---|---|
int | a BigInt, a number, or a decimal string (strings are safest above 2^53) |
string | a string |
bool | a boolean |
address | an ak_... string |
list(t) | a JavaScript array |
map(k, v) | a Map, not a plain object |
| a record | a plain object with exactly the record's field names |
| a variant | an object with one key, the constructor name, whose value is an array of arguments |
option(t) | undefined for None, the bare value for Some |
A Map is required where the ACI says map. create_community takes
meta_info : map(string, string), so pass new Map(). An object literal fails to encode.
Variants use the one-key-object form. To create a DAO payout vote:
await dao.add_vote(
{
subject: { VotePayout: ['ak_recipient...'] },
description: 'Pay the treasury out to the community wallet',
link: 'https://superhero.com/trends/daos',
},
{ amount: (555n * 10n ** 18n).toString() },
);Dry runs: quoting without spending gas
Two different things are called "dry run".
A non-stateful entrypoint is already free. price, sell_return, buy_price,
balance, meta_info, total_supply, has_community and every other read in these
contracts is declared without stateful in the ACI. The SDK resolves them through the node's
dry-run endpoint automatically: no transaction is created, no gas is spent, no account is
needed. That is why the price quote in the example above costs nothing.
A stateful entrypoint can be dry-run explicitly by passing callStatic: true. The node
executes the call against current state and returns the result and the events it would emit,
without signing or broadcasting anything:
// What would this sell return, and would it revert?
const dry = await sale.sell(count, 0, { callStatic: true });
console.log(dry.decodedResult); // the return value
console.log(dry.decodedEvents); // Sell, Burn, PriceChange
console.log(dry.result.gasUsed); // measured gasUse this before any write you are unsure about. A revert shows up as a thrown error in the dry run exactly as it would on chain, without costing a transaction fee.
Gas estimation and gas limits
result.result.gasUsed on a dry run is the measured consumption for that call against
current state, and result.gasPrice is the price the call paid. Set an explicit gasLimit
from the measurement with a margin:
const dry = await sale.buy(count, { amount, callStatic: true });
const gasLimit = Math.ceil(Number(dry.result.gasUsed) * 1.3);
await sale.buy(count, { amount, gasLimit, omitUnknown: true });Two caveats:
- State moves. A buy that lands after several other buys in the same micro block touches different map entries and can use more gas than the dry run measured. The 30% margin above is a starting point, not a guarantee.
create_communityis expensive. It deploys four contracts in one transaction (aCommunityManagement, aDAO, anAffiliationBondingCurveTokenSale, and the AEX-9 token the sale creates in its owninit), then makes three more cross-contract calls to wire them together, and optionally performs an initial buy on top. Do not trim its gas limit aggressively; let the SDK use its default unless you have measured repeatedly.
You can also set gasPrice explicitly. Leave it alone unless you are fighting for inclusion;
the SDK's default tracks the node's minimum.
Decoding events
Every mined write returns decodedEvents, already matched against the ACI's datatype event
declarations. Each entry has name, args and contract:
const result = await sale.buy(count, { amount, omitUnknown: true });
for (const e of result.decodedEvents) {
console.log(e.name, e.contract.address, e.args);
}
// Buy ct_sale... [ total_price, affiliation_amount, total_supply ]
// Mint ct_token... [ buyer, amount ]
// Mint ct_dao... [ buyer, protocol_dao_amount ]
// PriceChange ct_sale... [ previous_price, next_price ]A single buy emits Mint twice, once from the community token and once from the
ProtocolDAO token. Distinguish them by e.contract.address:
const tokenMint = result.decodedEvents.find(
(e) => e.name === 'Mint' && e.contract.address === tokenAddress,
);
const protocolMint = result.decodedEvents.find(
(e) => e.name === 'Mint' && e.contract.address !== tokenAddress,
);Events from a transaction you did not submit come from the middleware instead. See Events for the topic hashes and the middleware query.
Errors and revert reasons
A Sophia require or abort aborts the call and reverts every state change. The SDK
surfaces it as a thrown error whose message carries the contract's own string:
try {
await sale.sell(count, minReturn, { omitUnknown: true });
} catch (error) {
// error.message contains the abort string, e.g. "MINIMAL_RETURN_NOT_MEET"
console.error(error.message);
}Revert strings you are most likely to hit, with the contract that raises them:
| Revert string | Raised by | Means |
|---|---|---|
AE_AMOUNT_NOT_SUFFICIENT | token sale buy | You attached less AE than the quoted price. Re-quote and add slippage. |
MINIMAL_RETURN_NOT_MEET | token sale sell | The return fell below your minimal_return_required between quote and execution. |
ADD_VOTE_DAO_DONATION_MISSMATCH | DAO.add_vote | The attached amount is not exactly 555 AE. |
NO_COMMUNITY_MANAGEMENT_AVAILABLE | DAO.add_vote | The DAO has no linked community management contract. |
VOTE_ALREADY_APPLIED | DAO.apply_vote_subject | Someone already executed this vote. |
VOTE_RESULT_TIMEOUTED | DAO.apply_vote_subject | The execution window after close height has passed. |
VOTE_NOT_YET_FINAL | DAO.apply_vote_subject | The vote has not closed yet. |
COLLECTION_CREATION_DONATION_MISMATCH | create_collection | The attached amount is not exactly 555 AE. |
collection_ALREADY_EXISTS | create_collection | That collection name is taken. |
MINIMUM_REDEMPTION_AND_FEE_NOT_MEET | register_invitation_code | redemption_fee_cover is below 0.001 AE. |
INVITATION_AMOUNT_AND_FEE_NOT_MATCHING | register_invitation_code | Attached AE is not exactly (fee + amount) * number of invites. |
NOT_KNOWN_TOKEN_SALE | mint_protocol_dao_token | Called by something the factory did not deploy. |
Two failure modes that are not reverts and need separate handling:
- Insufficient balance. The transaction is rejected before execution. Check
aeSdk.getBalance(address)first. - Not mined.
awaiton a write resolves when the transaction is mined by default. PasswaitMined: falseto get the hash immediately and poll yourself. This is useful forcreate_community, so a UI can navigate to a pending token page straight away.
Selling needs an allowance first
sell moves your tokens with transfer_allowance, so the sale contract must be allowed to
spend them before the call. The allowance is granted on the token contract, to the sale
contract's address rewritten from ct_ to ak_:
const forAccount = saleAddress.replaceAll('ct_', 'ak_');
const existing = await token
.allowance({ from_account: aeSdk.address, for_account: forAccount })
.then((r) => r.decodedResult)
.catch(() => undefined);
if (existing === undefined) {
await token.create_allowance(forAccount, count);
} else {
const change = new BigNumber(count).minus(existing.toString()).toFixed();
await token.change_allowance(forAccount, change);
}
await sale.sell(count, minReturn, { omitUnknown: true });allowance returns option(int), so a never-granted allowance decodes to undefined and
you must create_allowance; an existing one is adjusted with change_allowance, which takes
a delta, not an absolute value. The same allowance step is required before
DAOVote.vote.
Read-only setup
If you never sign anything, drop the account entirely:
const aeSdk = new AeSdk({
nodes: [{ name: 'mainnet', instance: new Node('https://mainnet.aeternity.io') }],
});
const factory = await Contract.initialize({
aci: factoryAci,
address: factoryAddress,
onNode: aeSdk.api, // no onAccount
});
await factory.has_community(collectionId, 'MYTOKEN');Related
- Deployed Addresses: the addresses these examples need, and how to resolve them at runtime
- Events: the full event catalogue and how to read events you did not submit
- AffiliationBondingCurveTokenSale: the buy and sell entrypoints in detail
- FungibleTokenFull (AEX-9): the allowance model the sell flow depends on
- Agent CLI reference: ready-made scripts for buying, selling, creating tokens and inviting
- æternity JavaScript SDK contract guide (opens in a new tab): the upstream reference for
Contract.initializeand call options