Neow3j는 Java 플랫폼(Java, Kotlin, Android)을 사용하여 Neo dApp과 스마트 컨트랙트를 구축하기 위한 쉽고 신뢰할 수 있는 도구를 제공하는 개발 툴킷입니다. neow3j와 기술 문서에 대한 더 자세한 정보는 neow3j.io를 확인해주세요.
아직 neow3j 라이브러리를 사용하기 위한 환경을 설정하지 않았다면, neow3j 프로젝트 설정에 대한 튜토리얼을 여기에서 확인할 수 있습니다.
NEP-17은 Neo N3의 대체 가능한 토큰 표준입니다. 공식 문서는 여기에서 확인할 수 있습니다.
다음 예제 코드는 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
}
다음 하위 섹션에서는 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 메서드는 다음과 같이 구현됩니다. 메서드가 컨트랙트의 상태를 변경하지 않는 경우(즉, 읽기에만 사용되는 경우) @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());
}
컨트랙트는 gradle 플러그인을 사용하여 컴파일할 수 있습니다. 먼저 gradle.build 파일에서 className을 컨트랙트의 클래스 이름으로 설정합니다. 그런 다음 프로젝트의 루트 경로에서 gradle 작업 neow3jCompile을 실행하여 컨트랙트를 컴파일할 수 있습니다.
./gradlew neow3jCompile
출력은 ./build/neow3j 폴더에서 접근할 수 있으며, 다음 세 개의 파일을 포함해야 합니다:
AxLabsToken.manifest.json
AxLabsToken.nef
AxLabsToken.nefdbgnfo
note
파일명은 컨트랙트의 이름에 따라 달라질 수 있습니다. 여기를 참조하세요.
이제 컨트랙트의 .manifest.json과 .nef 파일을 사용하여 컨트랙트를 배포할 수 있습니다. Neow3j의 SDK를 사용하여 이를 수행할 수 있습니다. 매니페스트와 nef 파일로 컨트랙트를 배포하는 방법에 대한 예제는 여기에서 확인할 수 있습니다.
발생할 수 있는 문제를 자유롭게 보고해주세요. 백로그에 직접 포함하여 도움을 주시려면 여기에서 이슈를 열어주세요.