neow3j - Java로 NEP-17 Smart Contract 구현하기

네오 블록체인·2025년 10월 17일

Neo N3

목록 보기
6/8

Neow3j는 Java 플랫폼(Java, Kotlin, Android)을 사용하여 Neo dApp과 스마트 컨트랙트를 구축하기 위한 쉽고 신뢰할 수 있는 도구를 제공하는 개발 툴킷입니다. neow3j와 기술 문서에 대한 더 자세한 정보는 neow3j.io를 확인해주세요.

1. 설정

아직 neow3j 라이브러리를 사용하기 위한 환경을 설정하지 않았다면, neow3j 프로젝트 설정에 대한 튜토리얼을 여기에서 확인할 수 있습니다.

2. NEP-17 개요

NEP-17은 Neo N3의 대체 가능한 토큰 표준입니다. 공식 문서는 여기에서 확인할 수 있습니다.

3. NEP-17 예제 컨트랙트

다음 예제 코드는 NEP-17 표준을 지원하는 토큰의 가능한 구현을 나타냅니다.

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.NeoStandard;
import io.neow3j.devpack.contracts.ContractManagement;
import io.neow3j.devpack.events.Event3Args;

/**
 * Be aware that this contract is an example. It has not been audited and should not be used in production.
 */
@DisplayName("AxLabsToken")
@ManifestExtra(key = "name", value = "AxLabsToken")
@ManifestExtra(key = "author", value = "AxLabs")
@SupportedStandard(neoStandard = NeoStandard.NEP_17)
@Permission(contract = "*")
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);
            if (new ContractManagement().getContract(initialOwner) != null) {
                Contract.call(initialOwner, "onNEP17Payment", CallFlags.All, new Object[]{null, initialSupply, null});
            }
        }
    }

    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, new Object[]{from, amount, 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. 컨트랙트 분석

다음 하위 섹션에서는 NEP-17 예제 컨트랙트의 각 부분을 살펴보겠습니다.

임포트

임포트는 예제 컨트랙트에서 사용되는 neow3j devpack 클래스들을 보여줍니다. 지원되는 클래스와 메서드의 전체 개요는 neow3j devpack의 javadoc에서 확인할 수 있습니다.

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.NeoStandard;
import io.neow3j.devpack.contracts.ContractManagement;
import io.neow3j.devpack.events.Event3Args;

컨트랙트별 정보

스마트 컨트랙트 클래스 상단의 어노테이션은 컨트랙트별 정보를 나타냅니다. 스마트 컨트랙트에서 사용할 수 있는 어노테이션은 다음과 같습니다:

@DisplayName

컨트랙트의 이름을 지정합니다. 이 어노테이션이 없으면 클래스 이름이 컨트랙트 이름으로 사용됩니다.

@ManifestExtra

매니페스트의 extra 필드에 제공된 키-값 쌍 정보를 추가합니다. 여러 @ManifestExtra 어노테이션을 수집하기 위해 @ManifestsExtras를 사용할 수도 있습니다(단일 @ManifestExtra 어노테이션을 사용할 때와 동일한 결과).

@SupportedStandard

매니페스트의 supportedStandards 필드를 설정합니다. 공식 표준을 사용하려면 NeoStandard 열거형과 함께 neoStandard = 를 사용할 수 있습니다(여기 참조), 또는 사용자 정의 문자열 값과 함께 customStandard = 를 사용할 수 있습니다.

@Permission

스마트 컨트랙트가 호출할 수 있는 제3자 컨트랙트와 메서드를 지정합니다. 기본적으로(즉, 권한 어노테이션이 설정되지 않은 경우) 컨트랙트는 어떤 컨트랙트도 호출할 수 없습니다. contract = methods = 를 사용하여 각각 허용되는 컨트랙트와 메서드를 지정합니다. 이 예제의 권한은 모든 컨트랙트와 모든 메서드가 허용됨을 의미합니다.

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

상수

final 변수를 사용하여 컨트랙트에 대한 상수 값을 설정할 수 있습니다. 이러한 값들은 컨트랙트가 호출될 때 항상 로드되며, 컨트랙트가 배포된 후에는 변경할 수 없습니다. final 값이 메서드 호출을 포함하지 않는 경우(예: 원시 타입 또는 "name"과 같은 final String 값), 이러한 값들은 컴파일 중에 인라인됩니다.

:::note

모든 컨트랙트 상수와 모든 메서드는 static이어야 합니다(NeoVM에서 JVM의 객체 지향성이 다르기 때문).

:::
:::tip

이 예제 컨트랙트의 컨트랙트 소유자는 고정되어 있습니다(즉, final 변수입니다). 이러한 변수를 변경할 수 있는 방법을 제공하려면 final 변수로 저장하지 않아야 합니다. 대신 스토리지에 값으로 저장하여 메서드를 통해 수정할 수 있는 가능성을 제공해야 합니다.

:::

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

배포

배포 트랜잭션이 생성되면(컨트랙트와 기타 매개변수를 포함), 컨트랙트 데이터가 먼저 블록체인에 저장된 다음 네이티브 컨트랙트 ContractManagement가 스마트 컨트랙트의 deploy() 메서드를 호출합니다. neow3j에서는 해당 메서드가 @OnDeployment 어노테이션으로 표시됩니다. 예제에서는 스마트 컨트랙트가 배포될 때 initialSupply가 200,000,000으로 설정되고 스마트 컨트랙트 소유자에게 할당됩니다.

@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);
        if (new ContractManagement().getContract(initialOwner) != null) {
            Contract.call(initialOwner, "onNEP17Payment", CallFlags.All, new Object[]{null, initialSupply, null});
        }
    }
}

업데이트 및 삭제

컨트랙트를 업데이트하기 위해 다음 메서드는 먼저 컨트랙트 소유자가 트랜잭션을 증명했는지 확인한 다음 네이티브 ContractManagement.update() 메서드를 호출합니다. 스마트 컨트랙트를 업데이트할 때 스마트 컨트랙트의 코드와 매니페스트를 변경할 수 있습니다. 이는 컨트랙트가 프로그래밍 방식으로 스토리지 컨텍스트를 관리하는 방법을 업데이트할 수 있음을 의미합니다.

:::note

스마트 컨트랙트의 스크립트와 매니페스트를 변경하는 것 외에도, ContractManagement.update() 메서드는 결국 boolean update를 true로 설정하여 스마트 컨트랙트의 deploy() 메서드(위에 표시됨)를 호출합니다.

:::

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);
}

예제 컨트랙트는 스마트 컨트랙트를 삭제하는 옵션도 제공합니다. update() 메서드와 마찬가지로 먼저 컨트랙트 소유자가 트랜잭션을 증명했는지 확인한 다음 ContractManagement.destroy() 메서드를 호출합니다.

:::caution

스마트 컨트랙트에서 네이티브 메서드 ContractManagement.destroy()가 호출되면 전체 스마트 컨트랙트의 스토리지 컨텍스트가 삭제되고 컨트랙트를 더 이상 사용할 수 없습니다.

:::

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

NEP-17 메서드

필수 NEP-17 메서드는 다음과 같이 구현됩니다. 메서드가 컨트랙트의 상태를 변경하지 않는 경우(즉, 읽기에만 사용되는 경우) @Safe 어노테이션으로 주석을 달 수 있습니다. NEP-17 메서드 중에서 transfer() 메서드만 컨트랙트에 쓰기를 해야 하므로 안전하지 않은 것으로 주석이 달려있지 않습니다.

@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, new Object[]{from, amount, 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);
}

이벤트

NEP-17 표준은 from, to, amount 값을 포함하는 Transfer 이벤트를 요구합니다. 이를 위해 Event3Args 클래스를 @DisplayName 어노테이션과 함께 사용하여 매니페스트와 알림에서 표시될 이벤트 이름을 설정할 수 있습니다.

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

이벤트 변수는 해당 인수와 함께 fire() 메서드를 사용하여 이벤트를 효과적으로 발생시킬 수 있습니다. 예를 들어, 전송이 발생할 때마다 Transfer 이벤트(onTransfer 변수로 표현됨)가 발생해야 합니다.

onTransfer.fire(from, to, amount);

사용자 정의 메서드

예제 컨트랙트는 NEP-17 표준에 명시되지 않은 두 개의 사용자 정의 메서드를 포함합니다. contractOwner() 메서드는 단순히 컨트랙트 소유자의 스크립트 해시를 반환합니다.

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

프라이빗 헬퍼 메서드

프라이빗 메서드는 스마트 컨트랙트를 단순화하고 더 읽기 쉽게 만드는 데 사용할 수 있습니다. 다음 프라이빗 메서드들이 NEP-17 예제 컨트랙트에서 사용됩니다.

// 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. 컨트랙트 컴파일

컨트랙트는 gradle 플러그인을 사용하여 컴파일할 수 있습니다. 먼저 gradle.build 파일에서 className을 컨트랙트의 클래스 이름으로 설정합니다. 그런 다음 프로젝트의 루트 경로에서 gradle 작업 neow3jCompile을 실행하여 컨트랙트를 컴파일할 수 있습니다.

./gradlew neow3jCompile

출력은 ./build/neow3j 폴더에서 접근할 수 있으며, 다음 세 개의 파일을 포함해야 합니다:

AxLabsToken.manifest.json
AxLabsToken.nef
AxLabsToken.nefdbgnfo

note
파일명은 컨트랙트의 이름에 따라 달라질 수 있습니다. 여기를 참조하세요.

이제 컨트랙트의 .manifest.json.nef 파일을 사용하여 컨트랙트를 배포할 수 있습니다. Neow3j의 SDK를 사용하여 이를 수행할 수 있습니다. 매니페스트와 nef 파일로 컨트랙트를 배포하는 방법에 대한 예제는 여기에서 확인할 수 있습니다.

정보

발생할 수 있는 문제를 자유롭게 보고해주세요. 백로그에 직접 포함하여 도움을 주시려면 여기에서 이슈를 열어주세요.

profile
스마트 이코노미를 위한 퍼블릭 블록체인, 네오에 대한 모든것

0개의 댓글