Smart Contracts
Calling Contracts

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.js

The 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.json

A 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

StepDetail
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.initializeNeeds aci plus address. Pass onNode always, and onAccount only when you intend to sign. Without onAccount the instance can still do read calls.
.decodedResultEvery 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: trueDrops 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 typePass
inta BigInt, a number, or a decimal string (strings are safest above 2^53)
stringa string
boola boolean
addressan ak_... string
list(t)a JavaScript array
map(k, v)a Map, not a plain object
a recorda plain object with exactly the record's field names
a variantan 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 gas

Use 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_community is expensive. It deploys four contracts in one transaction (a CommunityManagement, a DAO, an AffiliationBondingCurveTokenSale, and the AEX-9 token the sale creates in its own init), 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 stringRaised byMeans
AE_AMOUNT_NOT_SUFFICIENTtoken sale buyYou attached less AE than the quoted price. Re-quote and add slippage.
MINIMAL_RETURN_NOT_MEETtoken sale sellThe return fell below your minimal_return_required between quote and execution.
ADD_VOTE_DAO_DONATION_MISSMATCHDAO.add_voteThe attached amount is not exactly 555 AE.
NO_COMMUNITY_MANAGEMENT_AVAILABLEDAO.add_voteThe DAO has no linked community management contract.
VOTE_ALREADY_APPLIEDDAO.apply_vote_subjectSomeone already executed this vote.
VOTE_RESULT_TIMEOUTEDDAO.apply_vote_subjectThe execution window after close height has passed.
VOTE_NOT_YET_FINALDAO.apply_vote_subjectThe vote has not closed yet.
COLLECTION_CREATION_DONATION_MISMATCHcreate_collectionThe attached amount is not exactly 555 AE.
collection_ALREADY_EXISTScreate_collectionThat collection name is taken.
MINIMUM_REDEMPTION_AND_FEE_NOT_MEETregister_invitation_coderedemption_fee_cover is below 0.001 AE.
INVITATION_AMOUNT_AND_FEE_NOT_MATCHINGregister_invitation_codeAttached AE is not exactly (fee + amount) * number of invites.
NOT_KNOWN_TOKEN_SALEmint_protocol_dao_tokenCalled 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. await on a write resolves when the transaction is mined by default. Pass waitMined: false to get the hash immediately and poll yourself. This is useful for create_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