Intermediate

 

How to develop a smart contract on Near Blockchain – Part 3

This tutorial is part three of a series on how to develop a smart contract on the Near blockchain. In this part, the focus is on transfers and actions that a smart contract can perform. Actions can be batched together and act as a unit. The transfer function can be used to send NEAR tokens from the contract to any other account on the network. The create function can be used to create sub-accounts directly from the smart contract, which can be useful for organizing accounts. The create_account function can be used to create other accounts outside the contract’s immediate sub-accounts, such as a .mainnet or .testnet account. Finally, the tutorial covers how to deploy a contract to a newly created account by pre-loading the byte-code to be deployed.

 

Transfers & Actions

Smart contracts can perform specific Actions such as transferring NEAR, or calling other contracts.

An important property of Actions is that they can be batched together when acting on the same contract. Batched actions act as a unit: they execute in the same receipt, and if any fails, then they all get reverted.

INFO: Actions can be batched only when they act on the same contract. You can batch calling two methods on a contract, but cannot call two methods on different contracts.

 

Transfer NEAR Ⓝ

You can send $NEAR from your contract to any other account on the network. The Gas cost for transferring $NEAR is fixed and is based on the protocol’s genesis config. Currently, it costs ~0.45 TGas.

  • JavaScript

import { NearBindgen, NearPromise, call } from 'near-sdk-js'
import { AccountId } from 'near-sdk-js/lib/types'

@NearBindgen({})
class Contract{
  @call({})
  transfer({ to, amount }: { to: AccountId, amount: bigint }) {
    NearPromise.new(to).transfer(amount);
  }
}

 

TIP: The only case where a transfer will fail is if the receiver account does not exist.

CAUTION: Remember that your balance is used to cover for the contract’s storage. When sending money, make sure you always leave enough to cover for future storage needs.

 

Function Call

Your smart contract can call methods in another contract. In the snippet bellow we call a method in a deployed Hello NEAR contract, and check if everything went right in the callback.

  • JavaScript

import { NearBindgen, near, call, bytes, NearPromise } from 'near-sdk-js'
import { AccountId } from 'near-sdk-js/lib/types'

const HELLO_NEAR: AccountId = "hello-nearverse.testnet";
const NO_DEPOSIT: bigint = BigInt(0);
const CALL_GAS: bigint = BigInt("10000000000000");

@NearBindgen({})
class Contract {
  @call({})
  call_method({}): NearPromise {
    const args = bytes(JSON.stringify({ message: "howdy" }))

    return NearPromise.new(HELLO_NEAR)
    .functionCall("set_greeting", args, NO_DEPOSIT, CALL_GAS)
    .then(
      NearPromise.new(near.currentAccountId())
      .functionCall("callback", bytes(JSON.stringify({})), NO_DEPOSIT, CALL_GAS)
    )
    .asReturn()
  }

  @call({privateFunction: true})
  callback({}): boolean {
    let result, success;
  
    try{ result = near.promiseResult(0); success = true }
    catch{ result = undefined; success = false }
  
    if (success) {
      near.log(`Success!`)
      return true
    } else {
      near.log("Promise failed...")
      return false
    }
  }
}DANGER

 

The snippet showed above is a low level way of calling other methods. We recommend make calls to other contracts as explained in the Cross-contract Calls section.

 

Create a Sub Account

Your contract can create direct sub accounts of itself, for example, user.near can create sub.user.near.

Accounts do NOT have control over their sub-accounts, since they have their own keys.

Sub-accounts are simply useful for organizing your accounts (e.g. dao.project.near, token.project.near).

  • JavaScript

import { NearBindgen, near, call, NearPromise } from 'near-sdk-js'

const MIN_STORAGE: bigint = BigInt("1000000000000000000000") // 0.001Ⓝ

@NearBindgen({})
class Contract {
  @call({payableFunction:true})
  create({prefix}:{prefix: String}) {
    const account_id = `${prefix}.${near.currentAccountId()}`

    NearPromise.new(account_id)
    .createAccount()
    .transfer(MIN_STORAGE)
  }
}

 

TIP: Notice that in the snippet we are transferring some money to the new account for storage

CAUTION: When you create an account from within a contract, it has no keys by default. If you don’t explicitly add keys to it or deploy a contract on creation then it will be locked.

 

Creating Other Accounts

Accounts can only create immediate sub-accounts of themselves.

If your contract wants to create a .mainnet or .testnet account, then it needs to call the create_account method of near or testnet root contracts.

  • JavaScript

import { NearBindgen, near, call, bytes, NearPromise } from 'near-sdk-js'

const MIN_STORAGE: bigint = BigInt("1820000000000000000000"); //0.00182Ⓝ
const CALL_GAS: bigint = BigInt("28000000000000");

@NearBindgen({})
class Contract {
  @call({})
  create_account({account_id, public_key}:{account_id: String, public_key: String}) {
    const args = bytes(JSON.stringify({ 
      "new_account_id": account_id,
      "new_public_key": public_key 
    }))

    NearPromise.new("testnet")
    .functionCall("create_account", args, MIN_STORAGE, CALL_GAS);
  }
}

 

 

Deploy a Contract

When creating an account you can also batch the action of deploying a contract to it. Note that for this, you will need to pre-load the byte-code you want to deploy in your contract.

  • Rust

use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize};
use near_sdk::{near_bindgen, env, Promise, Balance};

#[near_bindgen]
#[derive(Default, BorshDeserialize, BorshSerialize)]
pub struct Contract { }

const MIN_STORAGE: Balance = 1_100_000_000_000_000_000_000_000; //1.1Ⓝ
const HELLO_CODE: &[u8] = include_bytes!("./hello.wasm");

#[near_bindgen]
impl Contract {
  pub fn create_hello(&self, prefix: String){
    let account_id = prefix + "." + &env::current_account_id().to_string();
    Promise::new(account_id.parse().unwrap())
    .create_account()
    .transfer(MIN_STORAGE)
    .deploy_contract(HELLO_CODE.to_vec());
  }
}

 

TIP: If an account with a contract deployed does not have any access keys, this is known as a locked contract. When the account is locked, it cannot sign transactions therefore, actions can only be performed from within the contract code.

 

Add Keys

When you use actions to create a new account, the created account does not have any access keys, meaning that it cannot sign transactions (e.g. to update its contract, delete itself, transfer money).

There are two options for adding keys to the account:

  • add_access_key: adds a key that can only call specific methods on a specified contract.

  • add_full_access_key: adds a key that has full access to the account.

  • JavaScript

import { NearBindgen, near, call, NearPromise } from 'near-sdk-js'
import { PublicKey } from 'near-sdk-js/lib/types'

const MIN_STORAGE: bigint = BigInt("1000000000000000000000") // 0.001Ⓝ

@NearBindgen({})
class Contract {
  @call({})
  create_hello({prefix, public_key}:{prefix: String, public_key: PublicKey}) {
    const account_id = `${prefix}.${near.currentAccountId()}`

    NearPromise.new(account_id)
    .createAccount()
    .transfer(MIN_STORAGE)
    .addFullAccessKey(public_key)
  }
}

 

Notice that what you actually add is a “public key”. Whoever holds its private counterpart, i.e. the private-key, will be able to use the newly access key.

TIP: If an account with a contract deployed does not have any access keys, this is known as a locked contract. When the account is locked, it cannot sign transactions therefore, actions can only be performed from within the contract code.

 

Delete Account

There are two scenarios in which you can use the delete_account action:

  • As the last action in a chain of batched actions.

  • To make your smart contract delete its own account.

  • JavaScript

 
import { NearBindgen, near, call, NearPromise } from 'near-sdk-js'
import { AccountId } from 'near-sdk-js/lib/types'

const MIN_STORAGE: bigint = BigInt("1000000000000000000000") // 0.001Ⓝ

@NearBindgen({})
class Contract {
  @call({})
  create_delete({prefix, beneficiary}:{prefix: String, beneficiary: AccountId}) {
    const account_id = `${prefix}.${near.currentAccountId()}`

    NearPromise.new(account_id)
    .createAccount()
    .transfer(MIN_STORAGE)
    .deleteAccount(beneficiary)
  }

  @call({})
  self_delete({beneficiary}:{beneficiary: AccountId}) {
    NearPromise.new(near.currentAccountId())
    .deleteAccount(beneficiary)
  }
}

 

Cross-Contract Calls

Cross-contract calls allow your contract to interact with other deployed contracts. This is useful for:

  • Querying information from another contract.

  • Executing a method in another contract.

Cross-Contract Calls are Independent

The method in which you make the call and the method in which you receive the result are different.

Cross-Contract Calls are Asynchronous

There is a delay between the call and the callback in which everyone can still interact with your contract.

 

Snippet: Querying Information

While making your contract, it is likely that you will want to query information from another contract. Below, you can see a basic example in which we query the greeting message from our Hello NEAR example.

  • AssemblyScript

  • index.ts

  • external.ts

cross-contract-hello-as/contract/assembly/index.ts

loading...

 

See full example on GitHub

 

Snippet: Sending Information

Calling another contract passing information is also a common scenario. Bellow you can see a method that interacts with the Hello NEAR example to change its greeting message.

  • AssemblyScript

  • index.ts

  • external.ts

cross-contract-hello-as/contract/assembly/index.ts

loading...

 

See full example on GitHub

 

Promises

Cross-contract calls work by creating two promises in the network:

  • A promise to execute code in the external contract (Promise.create).

  • A promise to call back a different method in your contract with the result (Promise.then).

Both promises take the same arguments:

  • AssemblyScript

ContractPromise.create( "external_address", "method", "encoded_arguments", GAS, DEPOSIT )

 

  • The address of the contract you want to interact with

  • The method that you want to execute

  • The (encoded) arguments to pass to the method

  • The amount of GAS to use (deducted from the attached Gas)

  • The amount of NEAR to attach (deducted from your contract’s balance)

TIP: Notice that the callback could be made to any contract. This means that, if you want, the result could be potentially handled by another contract.

CAUTION: The fact that you are creating a Promise means that both the cross-contract call and callback will not execute immediately. In fact:

  • The cross-contract call will execute 1 or 2 blocks after your method finishes correctly.

  • The callback will then execute 1 or 2 blocks after the external method finishes (correctly or not)

 

Callback Method

If your method finishes correctly, then eventually your callback method will execute. This will happen whether the external contract finishes successfully or not. We repeat, if your original method finishes correctly, then your callback will always execute.

In the callback method you will have access to the result, which contains two important arguments:

  • status: Telling if the external method finished successfully or not

  • buffer: Having the value returned by the external method (if any)

TIP: The callback methods in your contract must be public, so it can be called when the second promise executes. However, they should be only callable by your contract. Always make sure to make it private by asserting that the predecessor is current_account_id. In rust this can be achieved using the #[private] decorator.

 

Checking Execution Status

  • AssemblyScript

  • index.ts

  • external.ts

cross-contract-hello-as/contract/assembly/index.ts

loading...

 

See full example on GitHub

 

Successful Execution

In case the call finishes successfully, the resulting object will have a status of 1, and the buffer will have the encoded result (if any). In order to recover the result you need to decode it from the resulting buffer:

  • AssemblyScript

cross-contract-hello-as/contract/assembly/index.ts

loading...

 

See full example on GitHub

 

Failed Execution

If the external method fails (i.e. it panics), then your callback will be executed anyway. Here you need to manually rollback any changes made in your contract during the original call. Particularly:

  • If the contract attached NEAR to the call, the funds are sent back to the contract’s account.

  • If the original method made any state changes (i.e. changed or stored data), they won’t be automatically reverted.

DANGER: If your original method finishes correctly then the callback executes even if the external method panics. Your state will not rollback automatically, and $NEAR will not be returned to the signer automatically. Always make sure to check in the callback if the external method failed, and manually rollback any operation if necessary.

 

Security Concerns

While writing cross-contract calls there is a significant aspect to keep in mind: all the calls are independent and asynchronous. In other words:

  • The method in which you make the call and method for the callback are independent.

  • There is a delay between the call and the callback, in which people can still interact with the contract

This has important implications on how you should handle the callbacks. Particularly:

  • Make sure you don’t leave the contract in a exploitable state between the call and the callback.

  • Manually rollback any changes to the state in the callback if the external call failed.

We have a whole security section dedicated to these specific errors, so please go and check it.