Skip to main content

neow3j - Implementing a NEP-17 Smart Contract in Java

AxLabs ·  · 11 min read · Source
neow3j

neow3j v3.22.1

Neow3j is a development toolkit that provides easy and reliable tools to build Neo dApps and Smart Contracts using the Java platform (Java, Kotlin, Android). Check out neow3j.io for more detailed information on neow3j and the technical documentation.

1. Setup#

If you haven't already set up your environment to use the neow3j library, you can check out our tutorial about setting up a neow3j project here.

2. NEP-17 Overview#

The NEP-17 is the fungible token standard on Neo N3. Have a look at its official documentation here.

3. Example NEP-17 Contract#

The following example code represents a possible implementation for a token that supports the NEP-17 standard.

package io.neow3j.examples.contractdevelopment.contracts;
import io.neow3j.devpack.ByteString;import io.neow3j.devpack.Contract;import io.neow3j.devpack.Hash160;import io.neow3j.devpack.Helper;import io.neow3j.devpack.Runtime;import io.neow3j.devpack.Storage;import io.neow3j.devpack.StorageContext;import io.neow3j.devpack.StorageMap;import io.neow3j.devpack.annotations.DisplayName;import io.neow3j.devpack.annotations.ManifestExtra;import io.neow3j.devpack.annotations.OnDeployment;import io.neow3j.devpack.annotations.Permission;import io.neow3j.devpack.annotations.Safe;import io.neow3j.devpack.annotations.SupportedStandard;import io.neow3j.devpack.constants.CallFlags;import io.neow3j.devpack.constants.NativeContract;import io.neow3j.devpack.constants.NeoStandard;import io.neow3j.devpack.contracts.ContractManagement;import io.neow3j.devpack.events.Event3Args;
@DisplayName("AxLabsToken")@ManifestExtra(key = "name", value = "AxLabsToken")@ManifestExtra(key = "author", value = "AxLabs")@SupportedStandard(neoStandard = NeoStandard.NEP_17)@Permission(nativeContract = NativeContract.ContractManagement)public class FungibleToken {
    static final int contractMapPrefix = 0;    static final byte[] contractOwnerKey = new byte[]{0x00};    static final byte[] totalSupplyKey = new byte[]{0x01};
    static final int assetMapPrefix = 1;
    // region deploy, update, destroy
    @OnDeployment    public static void deploy(Object data, boolean update) {        if (!update) {            StorageContext ctx = Storage.getStorageContext();            // Set the contract owner.            Hash160 initialOwner = (Hash160) data;            if (!Hash160.isValid(initialOwner)) Helper.abort("Invalid deployment parameter");            Storage.put(ctx, contractOwnerKey, initialOwner);            // Initialize the supply.            int initialSupply = 200_000_000;            Storage.put(ctx, totalSupplyKey, initialSupply);            // Allocate all tokens to the contract owner.            new StorageMap(ctx, assetMapPrefix).put(initialOwner, initialSupply);            onTransfer.fire(null, initialOwner, initialSupply);        }    }
    public static void update(ByteString script, String manifest) throws Exception {        if (!Runtime.checkWitness(contractOwner(Storage.getReadOnlyContext()))) {            throw new Exception("No authorization");        }        new ContractManagement().update(script, manifest);    }
    public static void destroy() throws Exception {        if (!Runtime.checkWitness(contractOwner(Storage.getReadOnlyContext()))) {            throw new Exception("No authorization");        }        new ContractManagement().destroy();    }
    // endregion deploy, update, destroy    // region NEP-17 methods
    @Safe    public static String symbol() {        return "ALT";    }
    @Safe    public static int decimals() {        return 2;    }
    @Safe    public static int totalSupply() {        return Storage.getInt(Storage.getReadOnlyContext(), totalSupplyKey);    }
    public static boolean transfer(Hash160 from, Hash160 to, int amount, Object[] data) throws Exception {        if (!Hash160.isValid(from) || !Hash160.isValid(to)) {            throw new Exception("The parameters 'from' and 'to' must be 20-byte addresses.");        }        if (amount < 0) {            throw new Exception("The parameter 'amount' must be greater than or equal to 0.");        }        StorageContext ctx = Storage.getStorageContext();        if (amount > getBalance(ctx, from) || !Runtime.checkWitness(from)) {            return false;        }
        if (from != to && amount != 0) {            deductFromBalance(ctx, from, amount);            addToBalance(ctx, to, amount);        }
        onTransfer.fire(from, to, amount);        if (new ContractManagement().getContract(to) != null) {            Contract.call(to, "onNEP17Payment", CallFlags.All, data);        }        return true;    }
    @Safe    public static int balanceOf(Hash160 account) throws Exception {        if (!Hash160.isValid(account)) {            throw new Exception("The parameter 'account' must be a 20-byte address.");        }        return getBalance(Storage.getReadOnlyContext(), account);    }
    // endregion NEP-17 methods    // region events
    @DisplayName("Transfer")    static Event3Args<Hash160, Hash160, Integer> onTransfer;
    // endregion events    // region custom methods
    @Safe    public static Hash160 contractOwner() {        return new StorageMap(Storage.getReadOnlyContext(), contractMapPrefix).getHash160(contractOwnerKey);    }
    // endregion custom methods    // region private helper methods
    // When storage context is already loaded, this is a cheaper method than `contractOwner()`.    private static Hash160 contractOwner(StorageContext ctx) {        return new StorageMap(ctx, contractMapPrefix).getHash160(contractOwnerKey);    }
    private static void addToBalance(StorageContext ctx, Hash160 key, int value) {        new StorageMap(ctx, assetMapPrefix).put(key.toByteArray(), getBalance(ctx, key) + value);    }
    private static void deductFromBalance(StorageContext ctx, Hash160 key, int value) {        int oldValue = getBalance(ctx, key);        new StorageMap(ctx, assetMapPrefix).put(key.toByteArray(), oldValue - value);    }
    private static int getBalance(StorageContext ctx, Hash160 key) {        return new StorageMap(ctx, assetMapPrefix).getIntOrZero(key.toByteArray());    }
    // endregion private helper methods
}

4. Contract Breakdown#

In the following subsections, we'll be looking at each part of the NEP-17 example contract.

Imports#

The imports show the neow3j devpack classes that are used in the example contract. Check out neow3j devpack's javadoc for a full overview of classes and methods that are supported.

package io.neow3j.examples.contractdevelopment.contracts;
import io.neow3j.devpack.ByteString;import io.neow3j.devpack.Contract;import io.neow3j.devpack.Hash160;import io.neow3j.devpack.Helper;import io.neow3j.devpack.Runtime;import io.neow3j.devpack.Storage;import io.neow3j.devpack.StorageContext;import io.neow3j.devpack.StorageMap;import io.neow3j.devpack.annotations.DisplayName;import io.neow3j.devpack.annotations.ManifestExtra;import io.neow3j.devpack.annotations.OnDeployment;import io.neow3j.devpack.annotations.Permission;import io.neow3j.devpack.annotations.Safe;import io.neow3j.devpack.annotations.SupportedStandard;import io.neow3j.devpack.constants.CallFlags;import io.neow3j.devpack.constants.NativeContract;import io.neow3j.devpack.constants.NeoStandard;import io.neow3j.devpack.contracts.ContractManagement;import io.neow3j.devpack.events.Event3Args;

Contract-specific Information#

Annotations on top of the smart contract's class represent contract-specific information. The following annotations can be used for in a smart contract:

@DisplayName

It specifies the contract's name. If this annotation is not present, the class name is used for the contract's name.

@ManifestExtra

Adds the provided key-value pair information in the manifest's extra field. You can also use @ManifestsExtras to gather multiple @ManifestExtra annotations (results in the same as when using single @ManifestExtra annotations).

@SupportedStandard

Sets the supportedStandards field in the manifest. You can use neoStandard = with the enum NeoStandard to use an official standard (see here), or customStandard = with a custom string value.

@Permission

Specifies, which third-party contracts and methods the smart contract is allowed to call. By default (i.e., if no permission annotation is set), the contract is not allowed to call any contract. Use contract = and methods = to specify, respectively, which contracts and methods are allowed.

For example, if you want to allow transferring NEO tokens from the contract, you can add the annotation @Permission(nativeContract = NativeContract.NeoToken, methods = "transfer").

@DisplayName("AxLabsToken")@ManifestExtra(key = "name", value = "AxLabsToken")@ManifestExtra(key = "author", value = "AxLabs")@SupportedStandard(neoStandard = NeoStandard.NEP_17)@Permission(nativeContract = NativeContract.ContractManagement)public class FungibleToken {

Constants#

You can set a constant value for the contract by using final variables. These values are always loaded when the contract is called and cannot be changed once the contract is deployed. If a final value does not include a method call (e.g., raw types, or a final String value, such as "name"), then these values are inlined during compilation.

note

All contract constants and all methods must be static (since the object-orientation of the JVM is different on the NeoVM).

tip

The contract owner of this example contract is fixed (i.e., it is a final variable). If you intend to provide a way to change such a variable, you should not store it as a final variable. Rather, you would store it as a value in the storage, which provides the possibility to be modified through a method.

static final int contractMapPrefix = 0;static final byte[] contractOwnerKey = new byte[]{0x00};static final byte[] totalSupplyKey = new byte[]{0x01};

Deploy#

Once a deployment transaction is made (containing the contract and other parameters), the contract data is first stored on the blockchain and then the native contract ContractManagement calls the smart contract's deploy() method. In neow3j, that method is marked with the annotation @OnDeployment. In the example, when the smart contract is deployed, the initialSupply is set to 200'000'000 and it is allocated to the smart contract's owner.

@OnDeploymentpublic static void deploy(Object data, boolean update) {    if (!update) {        StorageContext ctx = Storage.getStorageContext();        // Set the contract owner.        Hash160 initialOwner = (Hash160) data;        if (!Hash160.isValid(initialOwner)) Helper.abort("Invalid deployment parameter");        Storage.put(ctx, contractOwnerKey, initialOwner);        // Initialize the supply.        int initialSupply = 200_000_000;        Storage.put(ctx, totalSupplyKey, initialSupply);        // Allocate all tokens to the contract owner.        new StorageMap(ctx, assetMapPrefix).put(initialOwner, initialSupply);        onTransfer.fire(null, initialOwner, initialSupply);    }}

Update and Destroy#

In order to update the contract, the following method first checks that the contract owner witnessed the transaction and then the native ContractManagement.update() method is called. When updating a smart contract, you can change the smart contract's code and its manifest. This means that you can update how the contract programmatically manages its storage context.

note

Additionally to changing the smart contract's script and manifest, the method ContractManagement.update() eventually calls the smart contract's deploy() method (shown above) with the boolean update set to true.

public static void update(ByteString script, String manifest) throws Exception {    if (!Runtime.checkWitness(contractOwner(Storage.getReadOnlyContext()))) {        throw new Exception("No authorization");    }    new ContractManagement().update(script, manifest);}

The example contract also provides the option to destroy the smart contract. As well as the update() method, it first verifies that the contract owner witnessed the transaction and then calls the method ContractManagement.destroy() method.

caution

When the native method ContractManagement.destroy() is called from a smart contract, the whole smart contract's storage context is erased, and the contract can no longer be used.

public static void destroy() throws Exception {    if (!Runtime.checkWitness(contractOwner(Storage.getReadOnlyContext()))) {        throw new Exception("No authorization");    }    new ContractManagement().destroy();}

NEP-17 Methods#

The required NEP-17 methods are implemented as follows. If a method does not change the state of the contract (i.e., it is just used for reading), it can be annotated with the @Safe annotation. Out of the NEP-17 methods, only the transfer() method should be writing to the contract and is thus not annotated as safe.

@Safepublic static String symbol() {    return "ALT";}
@Safepublic static int decimals() {    return 2;}
@Safepublic static int totalSupply() {    return Storage.getInt(Storage.getReadOnlyContext(), totalSupplyKey);}
public static boolean transfer(Hash160 from, Hash160 to, int amount, Object[] data) throws Exception {    if (!Hash160.isValid(from) || !Hash160.isValid(to)) {        throw new Exception("The parameters 'from' and 'to' must be 20-byte addresses.");    }    if (amount < 0) {        throw new Exception("The parameter 'amount' must be greater than or equal to 0.");    }    StorageContext ctx = Storage.getStorageContext();    if (amount > getBalance(ctx, from) || !Runtime.checkWitness(from)) {        return false;    }
    if (from != to && amount != 0) {        deductFromBalance(ctx, from, amount);        addToBalance(ctx, to, amount);    }
    onTransfer.fire(from, to, amount);    if (new ContractManagement().getContract(to) != null) {        Contract.call(to, "onNEP17Payment", CallFlags.All, data);    }    return true;}
@Safepublic static int balanceOf(Hash160 account) throws Exception {    if (!Hash160.isValid(account)) {        throw new Exception("The parameter 'account' must be a 20-byte address.");    }    return getBalance(Storage.getReadOnlyContext(), account);}

Events#

The NEP-17 standard requires an event Transfer that contains the values from, to, and amount. For this, the class Event3Args can be used with the annotation @DisplayName to set the event's name that will be shown in the manifest and notifications when it has been fired.

@DisplayName("Transfer")    static Event3Args<Hash160, Hash160, Integer> onTransfer;

An event variable can effectively fire an event by using the fire() method with the corresponding arguments. For example, the Transfer event (represented by the onTransfer variable) should be fired whenever a transfer happens.

onTransfer.fire(from, to, amount);

Custom Methods#

The example contract contains two custom methods that are not specified in the NEP-17 standard. The method contractOwner() simply returns the script hash of the contract owner.

@Safepublic static Hash160 contractOwner() {    return new StorageMap(Storage.getReadOnlyContext(), contractMapPrefix).getHash160(contractOwnerKey);}

Private Helper Methods#

Private methods can be used to simplify and make the smart contract more readable. The following private methods are used in the NEP-17 example contract.

// When storage context is already loaded, this is a cheaper method than `contractOwner()`.private static Hash160 contractOwner(StorageContext ctx) {    return new StorageMap(ctx, contractMapPrefix).getHash160(contractOwnerKey);}
private static void addToBalance(StorageContext ctx, Hash160 key, int value) {    new StorageMap(ctx, assetMapPrefix).put(key.toByteArray(), getBalance(ctx, key) + value);}
private static void deductFromBalance(StorageContext ctx, Hash160 key, int value) {    int oldValue = getBalance(ctx, key);    new StorageMap(ctx, assetMapPrefix).put(key.toByteArray(), oldValue - value);}
private static int getBalance(StorageContext ctx, Hash160 key) {    return new StorageMap(ctx, assetMapPrefix).getIntOrZero(key.toByteArray());}

5. Compile the Contract#

The contract can be compiled using the gradle plugin. First, set the className in the file gradle.build to the contract's class name. Then, the gradle task neow3jCompile can be executed from the project's root path to compile the contract.

./gradlew neow3jCompile

The output is then accessible in the folder ./build/neow3j, and should contain the following three files:

AxLabsToken.manifest.jsonAxLabsToken.nefAxLabsToken.nefdbgnfo
note

The filenames can deviate according to what the contract's name is. See here.

Now, the contract's .manifest.json and .nef files can be used to deploy the contract. Neow3j's SDK can be used to do so. Check out the example here about how to deploy a contract with its manifest and nef files.

About#

Feel free to report any issues that might arise. Open an issue here to help us directly including it in our backlog.