We have the following functions in our reference implementation.
We use Candy as the Metadata storage under the hood because:
- With a Class we can set certain properties in the item as immutable and provide guarantees to the user.
- The Class/Maps are held internally as Maps so we can more easily search through them and do not have to iterate over the whole collection.
In this code, the set_nft is used to completely overwrite the object. update_nft uses the Candy.Properties package(borrowed from @quint) that allows you to specify and update graph to a Class such that you can make deep updates to a nested class and immutable arguments are honored.
When we return icrc7_token_metadata we dump all the Classes to standard Maps according to the Value schema(and other data types…you can see the mapping at the end). This loses some context for the user, but the application can choose to add another endpoint that provides the object as a full annotated candy value.
///hard sets an NFT metadata; For incremental updates use update_nft
public func set_nft(request: [SetNFTRequest], environment : ?Environment) : [Bool]{
//todo: Security at this layer?
//todo: where to handle minting and setting data
let results = Vec.new<Bool>();
label proc for(thisItem in request.vals()){
//does it currently exist?
switch(Map.get<Nat, CandyTypes.Candy>(state.nfts, Map.nhash, thisItem.token_id)){
case(null){};
case(?val){
//this nft is being updated and we need to de-index it.
switch(get_token_owner_canonical(thisItem.token_id)){
case(#err(_)){};
case(#ok(val)) ignore unindex_owner(thisItem.token_id, val);
};
};
};
ignore Map.put<Nat, CandyTypes.Candy>(state.nfts, Map.nhash, thisItem.token_id, thisItem.metadata);
Vec.add(results, true);
D.print("about to check canonical owner" # debug_show(thisItem));
switch(get_token_owner_canonical(thisItem.token_id)){
case(#ok(owner)){
D.print("about to index owner" # debug_show(thisItem));
ignore index_owner(thisItem.token_id, owner);
};
case(_){};
};
};
return Vec.toArray(results);
};
///updates an NFT metadata;
public func update_nft(request: [UpdateNFTRequest], environment : ?Environment) : Result.Result<[Bool], Text>{
//todo: Security at this layer?
//todo: where to handel minting and setting data
let results = Vec.new<Bool>();
label proc for(thisItem in request.vals()){
//does it currently exist?
switch(Map.get<Nat, CandyTypes.Candy>(state.nfts, Map.nhash, thisItem.token_id)){
case(null){};
case(?val){
var owner_found : ?Account = null;
//this nft is being updated and we need to de-index it.
switch(get_token_owner_canonical(thisItem.token_id)){
case(#err(_)){};
case(#ok(val)){
//do any of the updates affect the owner
for(thisUpdate in thisItem.updates.vals()){
if(thisUpdate.name == token_property_owner_account){
owner_found := ?val;
};
};
};
};
switch(val){
case(#Class(props)){
let updatedObject = switch(CandyProperties.updateProperties(props, thisItem.updates)){
case(#ok(val)) val;
case(#err(err)) {
Vec.add(results, false);
continue proc;
};
};
switch(owner_found){
case(?val){
ignore unindex_owner(thisItem.token_id, val);
};
case(null){};
};
ignore Map.put<Nat, CandyTypes.Candy>(state.nfts, Map.nhash, thisItem.token_id, #Class(updatedObject));
Vec.add(results, true);
switch(owner_found){
case(?val){
D.print("about to check canonical owner" # debug_show(thisItem));
switch(get_token_owner_canonical(thisItem.token_id)){
case(#ok(owner)){
D.print("about to index owner" # debug_show(thisItem));
ignore index_owner(thisItem.token_id, owner);
};
case(_){};
};
};
case(null){};
};
};
case(_) return #err("Only Class types supported by update");
};
};
};
};
return #ok(Vec.toArray(results));
};
Converting the Internally Stored Candy to Value:
///converts a candyshared value to the reduced set of ValueShared used in many places like ICRC3. Some types not recoverable
public func CandySharedToValue(x: CandyShared) : ValueShared {
switch(x){
case(#Text(x)) #Text(x);
case(#Map(x)) {
let buf = Buffer.Buffer<(Text, ValueShared)>(1);
for(thisItem in x.vals()){
buf.add((thisItem.0, CandySharedToValue(thisItem.1)));
};
#Map(Buffer.toArray(buf));
};
case(#Class(x)) {
let buf = Buffer.Buffer<(Text, ValueShared)>(1);
for(thisItem in x.vals()){
buf.add((thisItem.name, CandySharedToValue(thisItem.value)));
};
#Map(Buffer.toArray(buf));
};
case(#Int(x)) #Int(x);
case(#Int8(x)) #Int(Int8.toInt(x));
case(#Int16(x)) #Int(Int16.toInt(x));
case(#Int32(x)) #Int(Int32.toInt(x));
case(#Int64(x)) #Int(Int64.toInt(x));
case(#Ints(x)){
#Array(Array.map<Int,ValueShared>(x, func(x: Int) : ValueShared { #Int(x)}));
};
case(#Nat(x)) #Nat(x);
case(#Nat8(x)) #Nat(Nat8.toNat(x));
case(#Nat16(x)) #Nat(Nat16.toNat(x));
case(#Nat32(x)) #Nat(Nat32.toNat(x));
case(#Nat64(x)) #Nat(Nat64.toNat(x));
case(#Nats(x)){
#Array(Array.map<Nat,ValueShared>(x, func(x: Nat) : ValueShared { #Nat(x)}));
};
case(#Bytes(x)){
#Blob(Blob.fromArray(x));
};
case(#Array(x)) {
#Array(Array.map<CandyShared, ValueShared>(x, CandySharedToValue));
};
case(#Blob(x)) #Blob(x);
case(#Bool(x)) #Blob(Blob.fromArray([if(x==true){1 : Nat8} else {0: Nat8}]));
case(#Float(x)){#Text(Float.format(#exact, x))};
case(#Floats(x)){
#Array(Array.map<Float,ValueShared>(x, func(x: Float) : ValueShared { CandySharedToValue(#Float(x))}));
};
case(#Option(x)){ //empty array is null
switch(x){
case(null) #Array([]);
case(?x) #Array([CandySharedToValue(x)]);
};
};
case(#Principal(x)){
#Blob(Principal.toBlob(x));
};
case(#Set(x)) {
#Array(Array.map<CandyShared,ValueShared>(x, func(x: CandyShared) : ValueShared { CandySharedToValue(x)}));
};
case(#ValueMap(x)) {
#Array(Array.map<(CandyShared,CandyShared),ValueShared>(x, func(x: (CandyShared,CandyShared)) : ValueShared { #Array([CandySharedToValue(x.0), CandySharedToValue(x.1)])}));
};
//case(_){assert(false);/*unreachable*/#Nat(0);};
};
};
Usage from our test:
test("Update immutable and non-immutable NFT properties", func() {
//Arrange: Set up the ICRC7 instance and required parameters
let icrc7 = ICRC7.ICRC7(?icrc7_migration_state, testCanister, base_environment);
let token_id = 12; // Assuming a token ID for testing
let initialMetadata = #Class([
{immutable=false; name=ICRC7.token_property_owner_account; value = #Map([(ICRC7.token_property_owner_principal,#Blob(Principal.toBlob(testOwner)))]);},
{name="test"; value=#Text("initialTestValue"); immutable = false},
{name="test3"; value=#Text("immutableTestValue"); immutable = true}
]); // Define the initial metadata for testing
let targetMetadata = #Class([
{immutable=false; name=ICRC7.token_property_owner_account; value = #Map([(ICRC7.token_property_owner_principal,#Blob(Principal.toBlob(testOwner)))]);},
{name="test"; value=#Text("updatedTestValue"); immutable = false},
{name="test3"; value=#Text("immutableTestValue"); immutable = true}
]); // Define the initial metadata for testing
let updateImmutable = {name="test"; mode=#Set(#Text("updatedTestValue"))}; // Define an update for non-immutable property
let updateNonImmutable = {name="test3"; mode=#Set(#Text("updatedImmutableTestValue"));}; // Define an update for immutable property
let mintedNftMetadata = CandyTypesLib.unshare(initialMetadata);
let nft = icrc7.set_nft([{token_id=token_id;metadata=mintedNftMetadata;}], ?base_environment);
// Act and Assert: Attempt to update the immutable and non-immutable properties
let #ok(resultNonImmutableUpdate) = icrc7.update_nft([{token_id=token_id;updates=[updateImmutable];}], ?base_environment) else return assert(false);
D.print("resultNonImmutableUpdate" # debug_show(resultNonImmutableUpdate));
assert(
// Ensure the update for the immutable property fails and returns false
resultNonImmutableUpdate[0] == true//, "Update for immutable property should fail"
);
let #ok(resultImmutableUpdate) = icrc7.update_nft([{token_id=token_id;updates=[updateNonImmutable];}], ?base_environment) else return assert(false);
D.print("resultImmutableUpdate" # debug_show(resultImmutableUpdate));
assert(
// Ensure the update for the non-immutable property succeeds and returns true
resultImmutableUpdate[0] == false//, "Update for non-immutable property should succeed"
);
// Assert: Check if the updated metadata matches the expectation
let ?retrievedMetadata = icrc7.get_token_info(token_id) else return assert(false);
assert(
// Ensure the updated metadata matches the non-immutable update
CandyTypesLib.eq(CandyTypesLib.unshare(targetMetadata), retrievedMetadata)//,
//"Updated non-immutable property matches the expectations"
);
});