Uniq variants - On issue.b smart contract example
This page describes an example smart contract that utilizes smart contract notifications produced by eosio.nft.ft
contract when one of the actions within it are utilized. As a goal for this smart contract we chose to modify the token URI right after it is minted based on it's serial number. For tokens with even or odd serial number we will substitute one of 2 possible URIs.
Logic
When writing a smart contract that actively interacts with some other contract you need to either write some definitions to properly read the data from that other smart contract or you will need to get the definitions directly from the smart contract source code. This is needed since data on blockchain is stored in dense binary representation so without these definitions you will only be able to get the raw binary data from the smart contract which is not convenient to work with.
For this smart contract to properly interact with eosio.nft.ft
we will need information regarding the following tables:
token.b
- described usingtoken_v1
structure. This table stores information about all tokens of NFT standard v1next.token
- described usingnext_token_number
structure. This singleton stores the id of the next token that will be minted, by subtracting 1 you get the id of the token that was previously minted.
To know the layout of those tables refer to the following documents uniq metadata fields
In addition to the above we also need to define an action within our smart contract with interface that matches issue.b
action. This will cause eosio.nft.ft
contract to notify us when issue.b
action is executed and the account that will host this contract (here it will be onissue
) is involved in the minting process (e.g. onissue
is the issuer of the token). For that the following interface is re-created with an indication to listen for issue.b
action:
[[eosio::on_notify("eosio.nft.ft::issue.b")]]
void on_issue(const issue_wrap_v1& issue);
We will also add 2 extra actions to be able to configure which URI will be used depending on if serial number is odd or even. This data will be stored a simple singleton uricfg
and depending on the scope it will be either for even URIs (scope is 0) or for odd URIs (scope is 1). The actions themselves are even_uri
and odd_uri
and they simply write input string into the singleton.
Logic for on_issue
listener is to first deduce the token ID that was just minted by reading next.token
singleton, then to read the token.b
to get the serial number of the token. After that we simply read uricfg
singleton and get the URI to write based on the serial number of the token and do an inline call to settknmeta
. The settknmeta
action will be responsible for writing the new URI we want into the token data since our example contract does not have authority to change data of other smart contracts - we can only read the data and do inline action calls.
INFO
To be able to do the inline call to settknmeta
an extra permission is required for onissue
account. It is configured in the test using the following cleos
command:
await cleos(`set account permission onissue active --add-code onissue -p onissue@owner`)
This is equivalent to adding onissue@eosio.code
account permission inside onissue@active
. And effectively means that smart contract stored in onissue
account has same authority that onissue@active
would have
Disclaimers
WARNING
This example contract is not production ready and has some functionality and checks missing, be sure to do a proper testing if you intend to use any of the code provided in your solution
WARNING
This contract only listens to issue.b
action, but tokens may also be issued when issue
action is called after v1 actions are enabled. That means that in addition to listening to issue.b
action you may also want to listen to issue
action. This is only applicable in cases where you don't control the service that initiates the minting process (e.g. token is issued from 3rd party to your service). This example contract assumes you have full control over the minting process so only issue.b
action is considered
WARNING
issue.b
action supports minting multiple tokens at a time but this example contract will only update the very last token minted per issue.b
action. This again assumes you have full ownership of the minting process and can control the number of tokens minted at a time. If this is not the case the smart contract will require adjustments to support multiple tokens minted at a time.
WARNING
This contract assumes no one will be issuing tokens to onissue
contract so if someone actually does try to issue a token this example contract will try to modify it, but will fail since it will most likely not be the factory manager.
How to build and test
First step after writing the onissue.hpp
and onissue.cpp
will be to build the contract. Follow this for instructions of the build process.
As an alternative the build command using cdt-cpp
:
cdt-cpp ./src/onissue.cpp -abigen
After you successfully compile the contract you will have two files: onissue.abi
and onissue.wasm
both of them are needed for proper functionality of your contract on-chain.
The test provided in onissue.ultra_test.js
in the section below assumes that the compiled contract is located at pconfig.ultraRootPath + '/eosio.contracts/build/contracts/onissue'
where ultraRootPath
is the persistent path configured for ultratest
(e.g. the end result could be ~/ultra/eosio.contracts/build/contracts/onissue
). In your case it may not be true so be sure to modify the set contract
section of the test with correct path to your contract.
To run the test simply run the ultratest
with proper path to the test file:
ultratest -t ./onissue.ultra_test.js
Smart contract source code
onissue.hpp
#include <eosio/eosio.hpp>
#include <eosio/system.hpp>
#include <eosio/asset.hpp>
#include <eosio/singleton.hpp>
#include <eosio/binary_extension.hpp>
using namespace std;
using namespace eosio;
// Alternatively can include if available:
//#include <eosio.nft.ft/nft.common.hpp>
//#include <eosio.nft.ft/eosio.nft.ft.hpp>
namespace eosio {
// Declare table structure types
struct next_token_number {
uint64_t value;
EOSLIB_SERIALIZE( next_token_number, (value) )
};
typedef eosio::singleton<"next.token"_n, next_token_number> next_token_number_singleton;
struct token_v1 {
uint64_t id;
uint64_t token_factory_id;
time_point_sec mint_date;
uint32_t serial_number;
int64_t uos_payment;
optional<string> uri;
optional<checksum256> hash;
uint64_t primary_key()const { return id; }
EOSLIB_SERIALIZE( token_v1, (id)(token_factory_id)(mint_date)(serial_number)(uos_payment)(uri)(hash) )
};
typedef eosio::multi_index< "token.b"_n, token_v1 > token_table_v1;
// Get interface issue_wrap_v1 type from eosio.nft.ft contract
struct issue_token_config {
uint64_t token_factory_id;
uint32_t amount;
string custom_data;
EOSLIB_SERIALIZE( issue_token_config, (token_factory_id)(amount)(custom_data) )
};
typedef vector<issue_token_config> issue_token_config_vector;
struct issue_token_metadata{
optional<string> meta_uri;
optional<checksum256> meta_hash;
EOSLIB_SERIALIZE( issue_token_metadata, (meta_uri)(meta_hash) )
};
typedef vector<issue_token_metadata> issue_token_metadata_vector;
struct issue_wrap_v1 {
name to;
issue_token_config_vector token_configs;
string memo;
optional<name> authorizer;
optional<asset> maximum_uos_payment;
binary_extension<optional<issue_token_metadata_vector>> token_metadata;
EOSLIB_SERIALIZE( issue_wrap_v1, (to)(token_configs)(memo)(authorizer)(maximum_uos_payment)(token_metadata) )
};
}
class [[eosio::contract("onissue")]] onissue : public contract {
public:
using contract::contract;
[[eosio::action("even.uri")]]
void even_uri(const string& uri);
[[eosio::action("odd.uri")]]
void odd_uri(const string& uri);
onissue(name receiver, name code, datastream<const char*> ds)
: contract(receiver, code, ds) {
}
[[eosio::on_notify("eosio.nft.ft::issue.b")]]
void on_issue(const issue_wrap_v1& issue);
private:
struct [[eosio::table("uricfg"), eosio::contract("onissue")]] uri_config {
string value;
EOSLIB_SERIALIZE( uri_config, (value) )
};
//scope: 0 (even), 1 (odd)
typedef eosio::singleton< "uricfg"_n, uri_config > uri_config_singleton;
};
onissue.cpp
#include "onissue.hpp"
void onissue::even_uri(const string& uri) {
onissue::uri_config_singleton _s(get_self(), 0);
_s.set({.value = uri}, get_self());
}
void onissue::odd_uri(const string& uri) {
onissue::uri_config_singleton _s(get_self(), 1);
_s.set({.value = uri}, get_self());
}
void onissue::on_issue(const issue_wrap_v1& issue) {
// Multiple tokens could be issued by a single issue action.
// For the purposes of this smart contract we will assume that 'onissue' account will issue tokens
// only from the factories that this contract should apply to and that only one token will be issued at a time.
// If more granular logic is required then it is necessary to check issue.token_configs vector and check
// each token_factory_id to make sure it is one of the factories managed by 'onissue'.
// Additionally will need to check the issue.token_configs[i].amount and apply the logic for each token minted
eosio::name token_owner = issue.to;
// next token number singleton indicates the id of the next token that will be minted
// we can safely subtract 1 from it to get the id of that token that was just minted
next_token_number_singleton next_token_number_s("eosio.nft.ft"_n, 0);
uint64_t token_id = next_token_number_s.get().value - 1;
eosio::token_table_v1 tokens("eosio.nft.ft"_n, token_owner.value);
auto token_itr = tokens.find(token_id);
uint64_t serial_number_remainder = token_itr->serial_number % 2;
onissue::uri_config_singleton uri_config_s(get_self(), serial_number_remainder);
string new_uri = uri_config_s.get().value;
// if the eosio.nft.ft.hpp header is included the following wrapper can be used
//eosio::nft::set_token_meta_action settknmeta("eosio.nft.ft"_n, {{ get_self(), "active"_n }});
//settknmeta.send( token_id, token_owner, "modify on mint", new_uri, std::nullopt );
action(
permission_level{get_self(), "active"_n},
"eosio.nft.ft"_n,
"settknmeta"_n,
std::make_tuple( token_id, token_owner, std::string("modify on mint"), std::optional<string>(new_uri), std::optional<checksum256>() )
).send();
}
Validation test
onissue.ultra_test.js
module.exports = class onissue_test {
constructor() {}
requiredPlugins() {
return [];
}
requiresSystemContracts() {
return true;
}
nodeosConfigs() {
return {
config: {
'abi-serializer-max-time-ms': 100000,
},
}
}
requiredUnlimitedAccounts() {
return [];
}
requiredAccounts() {
return [
"onissue"
];
}
tests({assert, endpoint, cleos, rpc, api, ecc, pconfig}) {
const sleep = ms => new Promise(r => setTimeout(r, ms));
const {
activers,
default_create_wrap_v1,
create_test_conversion_rate,
create_token_factory_v1,
issue_nft_v1,
} = require('./eosio.nft.ft/shared_nft')(cleos, api, rpc, assert, this.requiredAccounts, endpoint);
const test_create = { ... default_create_wrap_v1,
stat: 0,
asset_manager: 'onissue',
asset_creator: 'onissue',
factory_uri: 'test',
factory_hash: 'd5768f8e2a7b1a8a9774dfb538e0a1928d0d9ac5f08bd781c21459b4308dc523',
default_token_uri: 'test',
default_token_hash: 'd5768f8e2a7b1a8a9774dfb538e0a1928d0d9ac5f08bd781c21459b4308dc523'
}
return {
'should activate v1 and allow 3rd party factories': async () => {
assert(await activers(), 'failed to activate v1');
await sleep(1000);
},
'should create test factory': async () => {
// need to have oracle rates available for NFT contract to work properly
await create_test_conversion_rate();
await create_token_factory_v1(test_create, "onissue", "onissue");
},
'should deploy onissue contract': async () => {
// provide KYC with dummy signature to be able to deploy the contract
assert(await cleos(`push action eosio.kyc registerkyc '["onissue","ultra","bfed20bb7e82fd800f653411d17d9ec1f51ff7e3ac5635113061af50a99d40e4","SIG_K1_KiyvFxSYrFNawpf747HFG4wDtq2wjV3dp6dbW6AijtMiKSzz5Gm5JBtLQ55yKnMpbsUuJwYc9AsDoTANMhVqKrfLXCmaid","SIG_K1_KiyvFxSYrFNawpf747HFG4wDtq2wjV3dp6dbW6AijtMiKSzz5Gm5JBtLQ55yKnMpbsUuJwYc9AsDoTANMhVqKrfLXCmaid"]' -p ultra.kyc`), "Did not register KYC for onissue account");
// modify the path to the compiled contract as needed
const contractPath = pconfig.ultraRootPath + '/eosio.contracts';
assert(await cleos(`set contract onissue ${contractPath}/build/contracts/onissue onissue.wasm onissue.abi -p onissue@active`), 'Did not set contract for onissue');
// allow inline actions to use active permission of 'onissue' account
assert(await cleos(`set account permission onissue active --add-code onissue -p onissue@owner`), "Could not set onissue permissions");
},
'should try to issue a token and fail because even.uri and odd.uri are not configured for smart contract': async() => {
assert(!await issue_nft_v1(test_create.asset_manager, [{token_factory_id: 0, amount: 1, custom_data: ''}], test_create.asset_manager, null, null, null, null, 'singleton does not exist'), 'was able to mint a token');
},
'should configure even.uri and odd.uri': async() => {
assert(await cleos(`push action onissue even.uri '["http://test.io/even.json"]' -p onissue`), "Was not able to configure even URI");
assert(await cleos(`push action onissue odd.uri '["http://test.io/odd.json"]' -p onissue`), "Was not able to configure odd URI");
},
'should issue 2 tokens, first one should have even URI, second one should have odd uri': async() => {
// first token has serial number of 1 - odd
// second token has serial number of 2 - even
// as mentioned in the contract code - will only issue 1 token at a time
// token_factory_id is 0 since it is the first factory ever created within this test so it will have id of 0
// in practice you need to get your token factory ID after creation from the chain
assert(await issue_nft_v1(test_create.asset_manager, [{token_factory_id: 0, amount: 1, custom_data: ''}], test_create.asset_manager), 'was not able to mint tokens');
assert(await issue_nft_v1(test_create.asset_manager, [{token_factory_id: 0, amount: 1, custom_data: ''}], test_create.asset_manager), 'was not able to mint tokens');
// check tokens owned by 'onissue' and make sure that first one has odd.json URI and second one has even.json URI
const tokens = await rpc.get_table_rows({json: true, code: 'eosio.nft.ft', scope: 'onissue', table: `token.b`});
assert(tokens.rows[0].uri === 'http://test.io/odd.json', "Wrong token 1 URI");
assert(tokens.rows[1].uri === 'http://test.io/even.json', "Wrong token 2 URI");
},
}
}
}