Neow3j는 Java 플랫폼(Java, Kotlin, Android)을 사용하여 Neo dApp과 스마트 컨트랙트를 구축하기 위한 쉽고 신뢰할 수 있는 도구를 제공하는 개발 툴킷입니다. neow3j와 기술 문서에 대한 더 자세한 정보는 neow3j.io를 확인해보세요.
아직 neow3j 라이브러리를 사용하기 위해 환경을 설정하지 않았다면, neow3j 프로젝트 설정에 대한 튜토리얼을 여기에서 확인할 수 있습니다.
NEP-11은 Neo N3의 대체 불가능한 토큰(NFT) 표준입니다. 공식 문서는 여기에서 확인할 수 있습니다.
다음 예제 코드는 NEP-11 표준을 지원하는 토큰의 가능한 구현을 나타냅니다.
:::info
이 예제 컨트랙트는 분할 불가능한 NFT를 지원합니다 (즉, decimals는 0과 같습니다).
NEP-11 표준은 분할 가능한 NFT를 지원해야 하는 경우 필요한 메서드들도 설명합니다. 분할 가능한 NFT에 필요한 일부 메서드들은 여기서 논의된 것들과 다릅니다. 더 자세한 내용은 NEP-11 표준 문서를 여기에서 확인하세요.
:::
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.Iterator;
import io.neow3j.devpack.Map;
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.FindOptions;
import io.neow3j.devpack.constants.NativeContract;
import io.neow3j.devpack.constants.NeoStandard;
import io.neow3j.devpack.contracts.ContractManagement;
import io.neow3j.devpack.events.Event3Args;
import io.neow3j.devpack.events.Event4Args;
@DisplayName("FurryFriends")
@ManifestExtra(key = "author", value = "AxLabs")
@SupportedStandard(neoStandard = NeoStandard.NEP_11)
@Permission(nativeContract = NativeContract.ContractManagement)
public class NonFungibleToken {
static final int contractMapPrefix = 0;
static final byte[] totalSupplyKey = new byte[]{0x00};
static final byte[] tokensOfKey = new byte[]{0x01};
static final byte[] contractOwnerKey = new byte[]{0x02};
static final int registryMapPrefix = 1;
static final int ownerOfMapPrefix = 2;
static final int balanceMapPrefix = 3;
static final int propNameMapPrefix = 8;
static final int propDescriptionMapPrefix = 9;
static final int propImageMapPrefix = 10;
static final int propTokenURIMapPrefix = 11;
static final String propName = "name";
static final String propDescription = "description";
static final String propImage = "image";
static final String propTokenURI = "tokenURI";
// endregion keys of key-value pairs in NFT properties
// region deploy, update, destroy
@OnDeployment
public static void deploy(Object data, boolean update) throws Exception {
if (!update) {
initializeContract((Hash160) data);
}
if (!Runtime.checkWitness(contractOwner())) {
throw new Exception("No authorization");
}
}
public static void update(ByteString script, String manifest) throws Exception {
if (!Runtime.checkWitness(contractOwner())) {
throw new Exception("No authorization");
}
new ContractManagement().update(script, manifest);
}
public static void destroy() throws Exception {
if (!Runtime.checkWitness(contractOwner())) {
throw new Exception("No authorization");
}
new ContractManagement().destroy();
}
// endregion deploy, update, destroy
// region NEP-11 methods
@Safe
public static String symbol() {
return "NEOW";
}
@Safe
public static int decimals() {
return 0;
}
@Safe
public static int totalSupply() {
return new StorageMap(Storage.getReadOnlyContext(), contractMapPrefix).getInt(totalSupplyKey);
}
@Safe
public static int balanceOf(Hash160 owner) throws Exception {
if (!Hash160.isValid(owner)) {
throw new Exception("The parameter 'owner' must be a 20-byte address.");
}
return getBalance(Storage.getReadOnlyContext(), owner);
}
@Safe
public static Iterator<ByteString> tokensOf(Hash160 owner) throws Exception {
if (!Hash160.isValid(owner)) {
throw new Exception("The parameter 'owner' must be a 20-byte address.");
}
return (Iterator<ByteString>) Storage.find(Storage.getReadOnlyContext(), createTokensOfPrefix(owner),
(byte) (FindOptions.KeysOnly | FindOptions.RemovePrefix));
}
public static boolean transfer(Hash160 to, ByteString tokenId, Object data) throws Exception {
if (!Hash160.isValid(to)) {
throw new Exception("The parameter 'to' must be a 20-byte address.");
}
if (tokenId.length() > 64) {
throw new Exception("The parameter 'tokenId' must be a valid NFT ID (64 or less bytes long).");
}
Hash160 owner = ownerOf(tokenId);
if (!Runtime.checkWitness(owner)) {
return false;
}
onTransfer.fire(owner, to, 1, tokenId);
if (owner != to) {
StorageContext ctx = Storage.getStorageContext();
new StorageMap(ctx, ownerOfMapPrefix).put(tokenId, to.toByteArray());
new StorageMap(ctx, createTokensOfPrefix(owner)).delete(tokenId);
new StorageMap(ctx, createTokensOfPrefix(to)).put(tokenId, 1);
decreaseBalanceByOne(ctx, owner);
increaseBalanceByOne(ctx, to);
}
if (new ContractManagement().getContract(to) != null) {
Contract.call(to, "onNEP11Payment", CallFlags.All, new Object[]{owner, 1, tokenId, data});
}
return true;
}
// endregion NEP-11 methods
// region non-divisible NEP-11 methods
@Safe
public static Hash160 ownerOf(ByteString tokenId) throws Exception {
if (tokenId.length() > 64) {
throw new Exception("The parameter 'tokenId' must be a valid NFT ID (64 or less bytes long).");
}
ByteString owner = new StorageMap(Storage.getReadOnlyContext(), ownerOfMapPrefix).get(tokenId);
if (owner == null) {
throw new Exception("This token id does not exist.");
}
return new Hash160(owner);
}
// endregion non-divisible NEP-11 methods
// region optional NEP-11 methods
@Safe
public static Iterator<Iterator.Struct<ByteString, ByteString>> tokens() {
return (Iterator<Iterator.Struct<ByteString, ByteString>>) new StorageMap(Storage.getReadOnlyContext(),
registryMapPrefix).find(FindOptions.RemovePrefix);
}
@Safe
public static Map<String, String> properties(ByteString tokenId) throws Exception {
if (tokenId.length() > 64) {
throw new Exception("The parameter 'tokenId' must be a valid NFT ID (64 or less bytes long).");
}
Map<String, String> p = new Map<>();
StorageContext ctx = Storage.getReadOnlyContext();
ByteString tokenName = new StorageMap(ctx, propNameMapPrefix).get(tokenId);
if (tokenName == null) {
throw new Exception("This token id does not exist.");
}
p.put(propName, tokenName.toString());
ByteString tokenDescription = new StorageMap(ctx, propDescriptionMapPrefix).get(tokenId);
if (tokenDescription != null) {
p.put(propDescription, tokenDescription.toString());
}
ByteString tokenImage = new StorageMap(ctx, propImageMapPrefix).get(tokenId);
if (tokenImage != null) {
p.put(propImage, tokenImage.toString());
}
ByteString tokenURI = new StorageMap(ctx, propTokenURIMapPrefix).get(tokenId);
if (tokenURI != null) {
p.put(propTokenURI, tokenURI.toString());
}
return p;
}
// endregion optional NEP-11 methods
// region events
@DisplayName("Mint")
private static Event3Args<Hash160, ByteString, Map<String, String>> onMint;
@DisplayName("Transfer")
private static Event4Args<Hash160, Hash160, Integer, ByteString> onTransfer;
// endregion events
// region custom methods
@Safe
public static Hash160 contractOwner() {
return new StorageMap(Storage.getReadOnlyContext(), contractMapPrefix).getHash160(contractOwnerKey);
}
public static void mint(Hash160 owner, ByteString tokenId, Map<String, String> properties) throws Exception {
if (!Runtime.checkWitness(contractOwner())) {
throw new Exception("No authorization");
}
StorageContext ctx = Storage.getStorageContext();
StorageMap registryMap = new StorageMap(ctx, registryMapPrefix);
if (registryMap.get(tokenId) != null) {
throw new Exception("This token id already exists.");
}
if (!properties.containsKey(propName)) {
throw new Exception("The properties must contain a value for the key 'name'.");
}
String tokenName = properties.get(propName);
new StorageMap(ctx, propNameMapPrefix).put(tokenId, tokenName);
if (properties.containsKey(propDescription)) {
String description = properties.get(propDescription);
new StorageMap(ctx, propDescriptionMapPrefix).put(tokenId, description);
}
if (properties.containsKey(propImage)) {
String image = properties.get(propImage);
new StorageMap(ctx, propImageMapPrefix).put(tokenId, image);
}
if (properties.containsKey(propTokenURI)) {
String tokenURI = properties.get(propTokenURI);
new StorageMap(ctx, propTokenURIMapPrefix).put(tokenId, tokenURI);
}
registryMap.put(tokenId, tokenId);
new StorageMap(ctx, ownerOfMapPrefix).put(tokenId, owner.toByteArray());
new StorageMap(ctx, createTokensOfPrefix(owner)).put(tokenId, 1);
increaseBalanceByOne(ctx, owner);
incrementTotalSupplyByOne(ctx);
onMint.fire(owner, tokenId, properties);
}
public static void burn(ByteString tokenId) throws Exception {
Hash160 owner;
try {
owner = ownerOf(tokenId);
} catch (Exception e) {
throw new Exception(e.getMessage());
}
if (!Runtime.checkWitness(owner)) {
throw new Exception("No authorization.");
}
StorageContext ctx = Storage.getStorageContext();
new StorageMap(ctx, registryMapPrefix).delete(tokenId);
new StorageMap(ctx, propNameMapPrefix).delete(tokenId);
new StorageMap(ctx, propDescriptionMapPrefix).delete(tokenId);
new StorageMap(ctx, propImageMapPrefix).delete(tokenId);
new StorageMap(ctx, propTokenURIMapPrefix).delete(tokenId);
new StorageMap(ctx, ownerOfMapPrefix).delete(tokenId);
new StorageMap(ctx, createTokensOfPrefix(owner)).delete(tokenId);
decreaseBalanceByOne(ctx, owner);
decrementTotalSupplyByOne(ctx);
onTransfer.fire(owner, null, 1, tokenId);
}
// endregion custom methods
// region private helper methods
private static void initializeContract(Hash160 contractOwner) {
StorageMap contractMap = new StorageMap(Storage.getStorageContext(), contractMapPrefix);
contractMap.put(totalSupplyKey, 0);
contractMap.put(contractOwnerKey, contractOwner);
}
// 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 int getBalance(StorageContext ctx, Hash160 owner) {
return new StorageMap(ctx, balanceMapPrefix).getIntOrZero(owner.toByteArray());
}
private static void increaseBalanceByOne(StorageContext ctx, Hash160 owner) {
new StorageMap(ctx, balanceMapPrefix).put(owner.toByteArray(), getBalance(ctx, owner) + 1);
}
private static void decreaseBalanceByOne(StorageContext ctx, Hash160 owner) {
new StorageMap(ctx, balanceMapPrefix).put(owner.toByteArray(), getBalance(ctx, owner) - 1);
}
private static void incrementTotalSupplyByOne(StorageContext ctx) {
StorageMap contractMap = new StorageMap(ctx, contractMapPrefix);
int updatedTotalSupply = contractMap.getInt(totalSupplyKey) + 1;
contractMap.put(totalSupplyKey, updatedTotalSupply);
}
private static void decrementTotalSupplyByOne(StorageContext ctx) {
StorageMap contractMap = new StorageMap(ctx, contractMapPrefix);
int updatedTotalSupply = contractMap.getInt(totalSupplyKey) - 1;
contractMap.put(totalSupplyKey, updatedTotalSupply);
}
private static byte[] createTokensOfPrefix(Hash160 owner) {
return Helper.concat(tokensOfKey, owner.toByteArray());
}
// endregion private helper methods
}
임포트는 예제 컨트랙트에서 사용되는 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.Iterator;
import io.neow3j.devpack.Map;
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.FindOptions;
import io.neow3j.devpack.constants.NativeContract;
import io.neow3j.devpack.constants.NeoStandard;
import io.neow3j.devpack.contracts.ContractManagement;
import io.neow3j.devpack.events.Event3Args;
import io.neow3j.devpack.events.Event4Args;
스마트 컨트랙트 클래스 상단의 어노테이션은 컨트랙트별 정보를 나타냅니다. 다음 어노테이션들이 예제 컨트랙트에서 사용됩니다:
@DisplayName
컨트랙트의 이름을 지정합니다. 이 어노테이션이 없으면 클래스 이름이 컨트랙트 이름으로 사용됩니다.
@ManifestExtra
매니페스트의 extra 필드에 제공된 키-값 쌍 정보를 추가합니다. 여러 @ManifestExtra 어노테이션을 수집하기 위해 @ManifestsExtras를 사용할 수도 있습니다 (단일 @ManifestExtra 어노테이션을 사용할 때와 동일한 결과).
@SupportedStandard
매니페스트의 supportedStandards 필드를 설정합니다. 공식 표준을 사용하려면 NeoStandard 열거형과 함께 neoStandard = 를 사용하거나 (자세한 내용은 여기 참조), 사용자 정의 문자열 값과 함께 customStandard = 를 사용할 수 있습니다.
Permission
스마트 컨트랙트가 호출할 수 있는 제3자 컨트랙트와 메서드를 지정합니다. 기본적으로 (즉, 권한 어노테이션이 설정되지 않은 경우) 컨트랙트는 어떤 컨트랙트도 호출할 수 없습니다. 허용되는 컨트랙트와 메서드를 각각 지정하려면 contract = 와 methods = 를 사용하세요.
예를 들어, 컨트랙트에서 NEO 토큰 전송을 허용하려면 @Permission(nativeContract = NativeContract.NeoToken, methods = "transfer") 어노테이션을 추가할 수 있습니다.
@DisplayName("FurryFriends")
@ManifestExtra(key = "author", value = "AxLabs")
@SupportedStandard(neoStandard = NeoStandard.NEP_11)
@Permission(nativeContract = NativeContract.ContractManagement)
public class NonFungibleToken {
final 변수를 사용하여 컨트랙트의 상수 값을 설정할 수 있습니다. 이러한 값들은 컨트랙트가 호출될 때 항상 로드되며, 컨트랙트가 배포된 후에는 변경할 수 없습니다. final 값이 메서드 호출을 포함하지 않는 경우 (예: 원시 타입 또는 "name"과 같은 final String 값), 이러한 값들은 컴파일 중에 인라인됩니다.
:::note
모든 컨트랙트 상수와 모든 메서드는 static이어야 합니다 (JVM의 객체 지향성이 NeoVM에서 다르기 때문).
:::
:::tip
이 예제 컨트랙트의 컨트랙트 소유자는 고정되어 있습니다 (즉, final 변수입니다). 이러한 변수를 변경할 수 있는 방법을 제공하려면 final 변수로 저장하지 않아야 합니다. 대신 스토리지에 값으로 저장하여 메서드를 통해 수정할 수 있도록 해야 합니다.
:::
static final int contractMapPrefix = 0;
static final byte[] totalSupplyKey = new byte[]{0x00};
static final byte[] tokensOfKey = new byte[]{0x01};
static final byte[] contractOwnerKey = new byte[]{0x02};
static final int registryMapPrefix = 1;
static final int ownerOfMapPrefix = 2;
static final int balanceMapPrefix = 3;
static final int propNameMapPrefix = 8;
static final int propDescriptionMapPrefix = 9;
static final int propImageMapPrefix = 10;
static final int propTokenURIMapPrefix = 11;
static final String propName = "name";
static final String propDescription = "description";
static final String propImage = "image";
static final String propTokenURI = "tokenURI";
배포 트랜잭션이 생성되면 (컨트랙트와 기타 매개변수를 포함), 컨트랙트 데이터가 먼저 블록체인에 저장된 다음 네이티브 컨트랙트 ContractManagement가 스마트 컨트랙트의 deploy() 메서드를 호출합니다. neow3j에서는 해당 메서드가 @OnDeployment 어노테이션으로 표시됩니다. 예제에서는 스마트 컨트랙트가 배포될 때 컨트랙트의 스토리지를 초기화하기 위해 private 메서드 initializeContract가 호출됩니다 (private 헬퍼 메서드 섹션에서 자세히 설명).
@OnDeployment
public static void deploy(Object data, boolean update) throws Exception {
if (!update) {
initializeContract((Hash160) data);
}
if (!Runtime.checkWitness(contractOwner())) {
throw new Exception("No authorization");
}
}
컨트랙트를 업데이트하기 위해 다음 메서드는 먼저 컨트랙트 소유자가 트랜잭션을 증명했는지 확인한 다음 네이티브 ContractManagement.update() 메서드를 호출합니다. 스마트 컨트랙트를 업데이트할 때 스마트 컨트랙트의 코드와 매니페스트를 변경할 수 있습니다. 이는 컨트랙트가 프로그래밍 방식으로 스토리지 컨텍스트를 관리하는 방법을 업데이트할 수 있음을 의미합니다.
:::note
스마트 컨트랙트의 스크립트와 매니페스트를 변경하는 것 외에도, ContractManagement.update() 메서드는 결국 update 불린을 true로 설정하여 스마트 컨트랙트의 deploy() 메서드(위에 표시됨)를 호출합니다.
:::
public static void update(ByteString script, String manifest) throws Exception {
if (!Runtime.checkWitness(contractOwner())) {
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())) {
throw new Exception("No authorization");
}
new ContractManagement().destroy();
}
필수 NEP-11 메서드들은 다음과 같이 구현됩니다. 메서드가 컨트랙트의 상태를 변경하지 않는 경우 (즉, 읽기 전용), @Safe 어노테이션으로 표시할 수 있습니다. 필수 NEP-11 메서드 중에서 transfer() 메서드만 컨트랙트의 스토리지에 쓰기를 해야 하므로 safe로 어노테이션되지 않습니다.
@Safe
public static String symbol() {
return "NEOW";
}
@Safe
public static int decimals() {
return 0;
}
@Safe
public static int totalSupply() {
return new StorageMap(Storage.getReadOnlyContext(), contractMapPrefix).getInt(totalSupplyKey);
}
@Safe
public static int balanceOf(Hash160 owner) throws Exception {
if (!Hash160.isValid(owner)) {
throw new Exception("The parameter 'owner' must be a 20-byte address.");
}
return getBalance(Storage.getReadOnlyContext(), owner);
}
@Safe
public static Iterator<ByteString> tokensOf(Hash160 owner) throws Exception {
if (!Hash160.isValid(owner)) {
throw new Exception("The parameter 'owner' must be a 20-byte address.");
}
return (Iterator<ByteString>) Storage.find(Storage.getReadOnlyContext(), createTokensOfPrefix(owner),
(byte) (FindOptions.KeysOnly | FindOptions.RemovePrefix));
}
public static boolean transfer(Hash160 to, ByteString tokenId, Object data) throws Exception {
if (!Hash160.isValid(to)) {
throw new Exception("The parameter 'to' must be a 20-byte address.");
}
if (tokenId.length() > 64) {
throw new Exception("The parameter 'tokenId' must be a valid NFT ID (64 or less bytes long).");
}
Hash160 owner = ownerOf(tokenId);
if (!Runtime.checkWitness(owner)) {
return false;
}
onTransfer.fire(owner, to, 1, tokenId);
if (owner != to) {
StorageContext ctx = Storage.getStorageContext();
new StorageMap(ctx, ownerOfMapPrefix).put(tokenId, to.toByteArray());
new StorageMap(ctx, createTokensOfPrefix(owner)).delete(tokenId);
new StorageMap(ctx, createTokensOfPrefix(to)).put(tokenId, 1);
decreaseBalanceByOne(ctx, owner);
increaseBalanceByOne(ctx, to);
}
if (new ContractManagement().getContract(to) != null) {
Contract.call(to, "onNEP11Payment", CallFlags.All, new Object[]{owner, 1, tokenId, data});
}
return true;
}
NEP-11 표준은 분할 불가능한 NFT 스마트 컨트랙트와 분할 가능한 NFT 스마트 컨트랙트를 모두 지정합니다. 이 스마트 컨트랙트는 분할 불가능하므로 (즉, decimals가 0), 특정 메서드 ownerOf를 구현해야 합니다. 이 메서드는 지정된 토큰 ID를 가진 토큰의 소유자 스크립트 해시를 반환합니다.
@Safe
public static Hash160 ownerOf(ByteString tokenId) throws Exception {
if (tokenId.length() > 64) {
throw new Exception("The parameter 'tokenId' must be a valid NFT ID (64 or less bytes long).");
}
ByteString owner = new StorageMap(Storage.getReadOnlyContext(), ownerOfMapPrefix).get(tokenId);
if (owner == null) {
throw new Exception("This token id does not exist.");
}
return new Hash160(owner);
}
NEP-11 표준은 tokens()와 properties()라는 두 개의 선택적 메서드를 설명합니다. 이는 이러한 이름과 매개변수를 가진 메서드가 구현되는 경우 표준을 따라야 함을 의미합니다. 아래에서 이 두 메서드의 구현을 볼 수 있습니다. tokens() 메서드는 registryMap을 반복하고 레지스트리에서 찾은 키-값 쌍을 기반으로 Iterator를 반환합니다. properties() 메서드는 컨트랙트의 스토리지에 저장된 제공된 토큰의 속성 맵을 반환합니다. 여기에는 이름과, 있는 경우 설명, 이미지, URI가 포함됩니다.
@Safe
public static Iterator<Iterator.Struct<ByteString, ByteString>> tokens() {
return (Iterator<Iterator.Struct<ByteString, ByteString>>) new StorageMap(Storage.getReadOnlyContext(),
registryMapPrefix).find(FindOptions.RemovePrefix);
}
@Safe
public static Map<String, String> properties(ByteString tokenId) throws Exception {
if (tokenId.length() > 64) {
throw new Exception("The parameter 'tokenId' must be a valid NFT ID (64 or less bytes long).");
}
Map<String, String> p = new Map<>();
StorageContext ctx = Storage.getReadOnlyContext();
ByteString tokenName = new StorageMap(ctx, propNameMapPrefix).get(tokenId);
if (tokenName == null) {
throw new Exception("This token id does not exist.");
}
p.put(propName, tokenName.toString());
ByteString tokenDescription = new StorageMap(ctx, propDescriptionMapPrefix).get(tokenId);
if (tokenDescription != null) {
p.put(propDescription, tokenDescription.toString());
}
ByteString tokenImage = new StorageMap(ctx, propImageMapPrefix).get(tokenId);
if (tokenImage != null) {
p.put(propImage, tokenImage.toString());
}
ByteString tokenURI = new StorageMap(ctx, propTokenURIMapPrefix).get(tokenId);
if (tokenURI != null) {
p.put(propTokenURI, tokenURI.toString());
}
return p;
}
NEP-11 표준은 from, to, amount, tokenId 값을 포함하는 Transfer 이벤트를 요구합니다. 이를 위해 Event4Args 클래스를 @DisplayName 어노테이션과 함께 사용하여 매니페스트와 알림에서 표시될 이벤트 이름을 설정할 수 있습니다. Mint 이벤트는 새로운 NFT가 발행될 때마다 발생하는 추가적인 사용자 정의 이벤트입니다.
@DisplayName("Mint")
private static Event3Args<Hash160, ByteString, Map<String, String>> onMint;
@DisplayName("Transfer")
private static Event4Args<Hash160, Hash160, Integer, ByteString> onTransfer;
이벤트 변수는 해당 인수와 함께 fire() 메서드를 사용하여 이벤트를 효과적으로 발생시킬 수 있습니다. 예를 들어, Transfer 이벤트(onTransfer 변수로 표현됨)는 전송이 발생할 때마다 발생해야 합니다.
onTransfer.fire(owner, to, 1, tokenId);
예제 컨트랙트는 NEP-11 표준에 명시되지 않은 일부 사용자 정의 메서드를 포함합니다.
contractOwner() 메서드는 단순히 컨트랙트 소유자의 스크립트 해시를 반환합니다.
mint() 메서드는 새로운 NFT 토큰을 발행하기 위해 컨트랙트 소유자가 호출할 수 있습니다. 이 메서드는 registryMap에 tokenId를, propertiesMap에 속성을, ownerMap에 소유자를 저장합니다. 또한 Mint 이벤트를 발생시키기 전에 소유자의 잔액과 총 공급량을 1씩 증가시킵니다.
burn() 메서드는 토큰의 소유자가 호출할 수 있습니다. 이 메서드는 토큰에 대한 모든 정보를 삭제하고 잔액과 총 공급량을 그에 따라 업데이트합니다. 토큰 소각의 의도가 스토리지를 해제할 필요가 없다면, 토큰을 소각 주소로 보낼 수도 있습니다.
@Safe
public static Hash160 contractOwner() {
return new StorageMap(Storage.getReadOnlyContext(), contractMapPrefix).getHash160(contractOwnerKey);
}
public static void mint(Hash160 owner, ByteString tokenId, Map<String, String> properties) throws Exception {
if (!Runtime.checkWitness(contractOwner())) {
throw new Exception("No authorization");
}
StorageContext ctx = Storage.getStorageContext();
StorageMap registryMap = new StorageMap(ctx, registryMapPrefix);
if (registryMap.get(tokenId) != null) {
throw new Exception("This token id already exists.");
}
if (!properties.containsKey(propName)) {
throw new Exception("The properties must contain a value for the key 'name'.");
}
String tokenName = properties.get(propName);
new StorageMap(ctx, propNameMapPrefix).put(tokenId, tokenName);
if (properties.containsKey(propDescription)) {
String description = properties.get(propDescription);
new StorageMap(ctx, propDescriptionMapPrefix).put(tokenId, description);
}
if (properties.containsKey(propImage)) {
String image = properties.get(propImage);
new StorageMap(ctx, propImageMapPrefix).put(tokenId, image);
}
if (properties.containsKey(propTokenURI)) {
String tokenURI = properties.get(propTokenURI);
new StorageMap(ctx, propTokenURIMapPrefix).put(tokenId, tokenURI);
}
registryMap.put(tokenId, tokenId);
new StorageMap(ctx, ownerOfMapPrefix).put(tokenId, owner.toByteArray());
new StorageMap(ctx, createTokensOfPrefix(owner)).put(tokenId, 1);
increaseBalanceByOne(ctx, owner);
incrementTotalSupplyByOne(ctx);
onMint.fire(owner, tokenId, properties);
}
public static void burn(ByteString tokenId) throws Exception {
Hash160 owner;
try {
owner = ownerOf(tokenId);
} catch (Exception e) {
throw new Exception(e.getMessage());
}
if (!Runtime.checkWitness(owner)) {
throw new Exception("No authorization.");
}
StorageContext ctx = Storage.getStorageContext();
new StorageMap(ctx, registryMapPrefix).delete(tokenId);
new StorageMap(ctx, propNameMapPrefix).delete(tokenId);
new StorageMap(ctx, propDescriptionMapPrefix).delete(tokenId);
new StorageMap(ctx, propImageMapPrefix).delete(tokenId);
new StorageMap(ctx, propTokenURIMapPrefix).delete(tokenId);
new StorageMap(ctx, ownerOfMapPrefix).delete(tokenId);
new StorageMap(ctx, createTokensOfPrefix(owner)).delete(tokenId);
decreaseBalanceByOne(ctx, owner);
decrementTotalSupplyByOne(ctx);
onTransfer.fire(owner, null, 1, tokenId);
}
Private 메서드는 스마트 컨트랙트를 단순화하고 더 읽기 쉽게 만드는 데 사용할 수 있습니다. 다음 private 메서드들이 NEP-11 예제 컨트랙트에서 사용됩니다.
private static void initializeContract(Hash160 contractOwner) {
StorageMap contractMap = new StorageMap(Storage.getStorageContext(), contractMapPrefix);
contractMap.put(totalSupplyKey, 0);
contractMap.put(contractOwnerKey, contractOwner);
}
// 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 int getBalance(StorageContext ctx, Hash160 owner) {
return new StorageMap(ctx, balanceMapPrefix).getIntOrZero(owner.toByteArray());
}
private static void increaseBalanceByOne(StorageContext ctx, Hash160 owner) {
new StorageMap(ctx, balanceMapPrefix).put(owner.toByteArray(), getBalance(ctx, owner) + 1);
}
private static void decreaseBalanceByOne(StorageContext ctx, Hash160 owner) {
new StorageMap(ctx, balanceMapPrefix).put(owner.toByteArray(), getBalance(ctx, owner) - 1);
}
private static void incrementTotalSupplyByOne(StorageContext ctx) {
StorageMap contractMap = new StorageMap(ctx, contractMapPrefix);
int updatedTotalSupply = contractMap.getInt(totalSupplyKey) + 1;
contractMap.put(totalSupplyKey, updatedTotalSupply);
}
private static void decrementTotalSupplyByOne(StorageContext ctx) {
StorageMap contractMap = new StorageMap(ctx, contractMapPrefix);
int updatedTotalSupply = contractMap.getInt(totalSupplyKey) - 1;
contractMap.put(totalSupplyKey, updatedTotalSupply);
}
private static byte[] createTokensOfPrefix(Hash160 owner) {
return Helper.concat(tokensOfKey, owner.toByteArray());
}
컨트랙트는 gradle 플러그인을 사용하여 컴파일할 수 있습니다. 먼저 gradle.build 파일에서 className을 컨트랙트의 클래스 이름으로 설정합니다. 그런 다음 프로젝트의 루트 경로에서 gradle 작업 neow3jCompile을 실행하여 컨트랙트를 컴파일합니다.
./gradlew neow3jCompile
출력은 ./build/neow3j 폴더에서 접근할 수 있으며, 다음 세 개의 파일을 포함해야 합니다:
FurryFriends.manifest.json
FurryFriends.nef
FurryFriends.nefdbgnfo
:::note
파일명은 컨트랙트의 이름에 따라 달라질 수 있습니다. 여기를 참조하세요.
:::
이제 컨트랙트의 .manifest.json과 .nef 파일을 사용하여 컨트랙트를 배포할 수 있습니다. Neow3j의 SDK를 사용하여 이를 수행할 수 있습니다. 매니페스트와 nef 파일로 컨트랙트를 배포하는 방법에 대한 예제는 여기에서 확인하세요.
발생할 수 있는 문제를 자유롭게 보고해주세요. 백로그에 직접 포함할 수 있도록 여기에서 이슈를 열어주세요.