Contracts
Introduction
The smart contract language of the æternity blockchain is Sophia. It is a functional language in the ML family, strongly typed and has restricted mutable state.
Before interacting with contracts using the SDK you should get familiar with Sophia itself first. Have a look into aepp-sophia-examples and start rapid prototyping using AEstudio.
The SDK needs to interact with following components in order to enable smart contract interactions on the æternity blockchain:
- æternity (host your own one or use the public testnet node at
https://testnet.aeternity.io
) - aesophia_http (host your own one or use the public compiler at
https://compiler.aepps.com
)
Note:
- For production deployments you should always host these services by yourself.
1. Specify imports
// node.js import
const { AeSdk, MemoryAccount, Node } = require('@aeternity/aepp-sdk')
// ES import
import { AeSdk, MemoryAccount, Node } from '@aeternity/aepp-sdk'
2. Create an instance of the SDK
When creating an instance of the SDK you need to provide an account which will be used to sign transactions like ContractCreateTx
and ContractCallTx
that will be broadcasted to the network.
const node = new Node('https://testnet.aeternity.io') // ideally host your own node
const account = new MemoryAccount({
// provide a valid keypair with your secretKey and publicKey
keypair: { secretKey: SECRET_KEY, publicKey: PUBLIC_KEY }
})
const aeSdk = new AeSdk({
nodes: [
{ name: 'testnet', instance: node }
],
compilerUrl: 'https://compiler.aepps.com', // ideally host your own compiler
})
await aeSdk.addAccount(accoount, { select: true })
Note:
- You can provide multiple accounts to the SDK.
- For each transaction you can choose a specific account to use for signing (by default the first account will be used), see transaction options.
- This is specifically important and useful for writing tests.
3. Initialize the contract instance
By source code
const sourceCode = ... // source code of the contract
const contractInstance = await aeSdk.getContractInstance({ source: sourceCode })
Note:
- If your contract includes external dependencies which are not part of the standard library you should initialize the contract using:
const fileSystem = ... // key-value map with name of the include as key and source code of the include as value const contractInstance = await aeSdk.getContractInstance({ source: sourceCode, fileSystem })
By ACI and bytecode
If you pre-compiled the contracts you can also initialize a contract instance by providing ACI and bytecode:
const aci = ... // ACI of the contract
const bytecode = ... // bytecode of the contract
const contractInstance = await aeSdk.getContractInstance({ aci, bytecode })
By ACI and contract address
In many cases an application doesn't need to deploy a contract or verify its bytecode. In this case you'd want to initialize the instance by just providing the ACI and the contract address. This is also possible:
const aci = ... // ACI of the contract
const contractAddress = ... // the address of the contract
const contractInstance = await aeSdk.getContractInstance({ aci, contractAddress })
Options
- Following attributes can be provided via
options
togetContractInstance
:aci
(default: obtained via http compiler)- The Contract ACI.
contractAddress
- The address where the contract is located at.
- To be used if a contract is already deployed.
fileSystem
(default: {})- Key-value map with name of the include as key and source code of the include as value.
validateBytecode
(default: false)- Compare source code with on-chain version.
- other transaction options which will be provided to every transaction that is initiated using the contract instance. You should be aware that:
- For most of these additional options it doesn't make sense to define them at contract instance level.
- You wouldn't want to provide an
amount
to each transaction or use the samenonce
which would result in invalid transactions. - For options like
ttl
orgasPrice
it does absolutely make sense to set this on contract instance level.
4. Deploy the contract
If you have a Sophia contract source code that looks like this:
contract Increment =
record state =
{ count: int }
entrypoint init(start: int) =
{ count = start }
stateful entrypoint increment(value: int) =
put(state{ count = state.count + value })
entrypoint get_count() =
state.count
The contract can be deployed using the contractInstance
in two different ways:
const tx = await contractInstance.deploy([1]) // recommended
// or
const tx = await contractInstance.methods.init(1)
// after successful deployment you can look up the transaction and the deploy information
console.log(tx)
console.log(contractInstance.deployInfo) // { owner, transaction, address, result, rawTx }
Note:
- Deployment is only possible if the contract instance was initialized by providing source code or bytecode.
- The
init
entrypoint is a special function which is only called once for deployment, initializes the contract's state and doesn't require thestateful
declaration. - In Sophia all
public functions
are calledentrypoints
and need to be declared asstateful
if they should produce changes to the state of the smart contract, seeincrement(value: int)
.
5. Call contract entrypoints
a) Stateful entrypoints
According to the example above you can call the stateful
entrypoint increment
by using one of the following lines:
const tx = await contractInstance.methods.increment(3) // recommended
// or
const tx = await contractInstance.methods.increment.send(3)
// or
const tx = await contractInstance.call('increment', [3])
Note:
- The functions
send
andcall
provide an explicit way to tell the SDK to sign and broadcast the transaction. - When using the
increment
function directly the SDK will automatically determine if it's astateful
entrypoint.
b) Regular entrypoints
The æternity node can expose an API endpoint that allows to execute a dry-run
for a transaction. You can make use of that functionality to get the result of entrypoints that don't execute state changes. Following lines show how you can do that using the SDK for the get_count
entrypoint of the example above:
const tx = await contractInstance.methods.get_count() // recommended
// or
const tx = await contractInstance.methods.get_count.get()
// access the decoded result returned by the execution of the entrypoint
console.log(tx.decodedResult);
Note:
- The functions
get
andcallStatic
provide an explicit way to tell the SDK to perform adry-run
and to NOT broadcast the transaction. - When using the
get_count
function directly the SDK will automatically determine that the function is not declaredstateful
and thus perform adry-run
, too.
c) Payable entrypoints
You will probably also write functions that require an amount of aettos
to be provided. These functions must be declared with payable
and (most likely) stateful
. Let's assume you have declared following Sophia entrypoint which checks if a required amount of aettos
has been provided before it continues execution:
payable stateful entrypoint fund_project(project_id: int) =
require(Call.value >= 50, 'at least 50 aettos need to be provided')
// further logic ...
In order to successfully call the fund_project
entrypoint you need to provide at least 50 aettos
. You can do this by providing the desired amount of aettos
using one of the following lines:
const tx = await contractInstance.methods.fund_project(1, { amount: 50 }) // recommended
// or
const tx = await contractInstance.methods.fund_project.send(1, { amount: 50 })
// or
const tx = await contractInstance.call('fund_project', [1], { amount: 50 })
Transaction options
As already stated various times in the guide it is possible to provide transaction options as object to a function of the SDK that builds and potentially broadcasts a transaction. This object can be passed as additional param to each of these functions and overrides the default settings.
Sophia datatype cheatsheet
Sometimes you might wonder how to pass params to the JavaScript method that calls an entrypoint of your Sophia smart contract. The following table may help you out.
Type | Sophia entrypoint definition | JavaScript method call |
---|---|---|
int | add_two(one: int, two: int) |
add_two(1 , 2) |
address | set_owner(owner: address) |
set_owner('ak_1337...') |
bool | is_it_true(answer: bool) |
is_it_true(true) |
bits | give_me_bits(input: bits) |
give_me_bits(0b10110n) |
bytes | get_bytes(test: bytes(3)) |
get_bytes(new Uint8Array([0x1, 0x1f, 0x10])) |
string | hello_world(say_hello: string) |
hello_world('Hello!') |
list | have_a_few(candy: list(string)) |
have_a_few(['Skittles', 'M&Ms', 'JellyBelly']) |
tuple | a_few_things(things: (string * int * map(address, bool))) |
a_few_things(['hola', 3, new Map([['ak_1337...', true]])]) |
record | record user = { firstname: string, lastname: string } get_firstname(input: user): string |
get_firstname({'firstname': 'Alfred', 'lastname': 'Mustermann'}) |
map | balances(values: map(address, int)) |
balances(new Map([['ak_1337...', 123], ['ak_FCKGW...', 321], ['ak_Rm5U...', 999]])) |
option() | number_defined(value: option(int)): bool = Option.is_some(value) |
// the datatype in the option() number_defined(1337) // int in this case |
hash | a_gram(of: hash) |
// 32 bytes signature a_gram('af01...490f') |
signature | one_signature(sig: signature) |
// 64 bytes signature one_signature('af01...490f') |
functions | (Higher order) functions are not allowed in entrypoint params |