From 89d2d9a6ee39bae863d5f518360272f854a43908 Mon Sep 17 00:00:00 2001 From: patnorris Date: Tue, 4 Nov 2025 14:59:19 +0100 Subject: [PATCH 01/29] Add initial code snippets --- src/GameState/src/Main.mo | 239 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index c564fcb..0c1c488 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8170,6 +8170,245 @@ actor class GameStateCanister() = this { }; }; */ +// NFT compatibility for mAIners listed on marketplace (ICRC7 and ICRC37) + // Static endpoints, see example: https://github.com/PanIndustrial-Org/icrc_nft.mo/blob/main/example/main.mo + let icrc7Symbol : Text = "MAINERS"; + let icrc7Name : Text = "funnAI mAIners"; + let icrc7Description : Text = "mAIner AI agents listed on the funnAI marketplace."; + let icrc7Logo : Text = "https://funnai.onicai.com/funnai.webp"; + + public query func icrc7_symbol() : async Text { + return icrc7Symbol; + }; + + public query func icrc7_name() : async Text { + return icrc7Name; + }; + + public query func icrc7_description() : async ?Text { + return ?icrc7Description; + }; + + public query func icrc7_logo() : async ?Text { + return ?icrc7Logo; + }; + + public query func icrc7_max_memo_size() : async ?Nat { + return ?100; // TODO: placeholder + }; + + public query func icrc7_tx_window() : async ?Nat { + return ?100; // TODO: placeholder + }; + + public query func icrc7_permitted_drift() : async ?Nat { + return ?100; // TODO: placeholder + }; + + public query func icrc7_total_supply() : async Nat { + return icrc7().get_stats().nft_count; // TODO: number of listings + }; + + public query func icrc7_supply_cap() : async ?Nat { + let currentNumberOfMainers = getNumberMainerAgents({ mainerType : Types.MainerAgentCanisterType = #ShareAgent; }); + return ?currentNumberOfMainers; + }; + + public query func icrc37_max_approvals_per_token_or_collection() : async ?Nat { + return ?1; // TODO: placeholder + }; + + public query func icrc7_max_query_batch_size() : async ?Nat { + return ?1; // TODO: placeholder + }; + + public query func icrc7_max_update_batch_size() : async ?Nat { + return ?1; // TODO: placeholder + }; + + public query func icrc7_default_take_value() : async ?Nat { + return ?100; // TODO: placeholder + }; + + public query func icrc7_max_take_value() : async ?Nat { + return ?100; // TODO: placeholder + }; + + public query func icrc7_atomic_batch_transfers() : async ?Bool { + return ?true; // TODO: placeholder + }; + + public query func icrc37_max_revoke_approvals() : async ?Nat { + return ?1; // TODO: placeholder + }; + + public query func icrc7_collection_metadata() : async [(Text, Value)] { + let metadata : [(Text, Value)] = [ + ("ICRC-7:Symbol", #Text(icrc7Symbol)), + ("ICRC-7:Name", #Text(icrc7Name)), + ("ICRC-7:Description", #Text(icrc7Description)), + ("ICRC-7:Logo", #Text(icrc7Logo)) + ]; + + return metadata; + }; + + + public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, Value)]]{ + return null; // TODO: placeholder: only allow 1 token id and retrieve info for it + }; + + public query func icrc7_owner_of(token_ids: OwnerOfRequest) : async OwnerOfResponse { + return null; // TODO: placeholder: only allow 1 token id and retrieve info for it + }; + + public query func icrc7_balance_of(accounts: BalanceOfRequest) : async BalanceOfResponse { + return null; // TODO: placeholder: only allow 1 token id and retrieve info for it + }; + + public query func icrc7_tokens(prev: ?Nat, take: ?Nat) : async [Nat] { + return []; // TODO: Retrieve all listed mAIners + }; + + public query func icrc7_tokens_of(account: Account, prev: ?Nat, take: ?Nat) : async [Nat] { + return []; // TODO: Retrieve all listed mAIners for account + }; + + public query func icrc37_is_approved(args: [IsApprovedArg]) : async [Bool] { + return [false]; // TODO: only allow 1 token id and check if listed + }; + + /* public query func icrc37_get_token_approvals(token_ids: [Nat], prev: ?TokenApproval, take: ?Nat) : async [TokenApproval] { + + return icrc37().get_token_approvals(token_ids, prev, take); + }; */ + + /* public query func icrc37_get_collection_approvals(owner : Account, prev: ?CollectionApproval, take: ?Nat) : async [CollectionApproval] { + + return icrc37().get_collection_approvals(owner, prev, take); + }; */ + + public query func icrc10_supported_standards() : async ICRC7.SupportedStandards { + //TODO: ICRC-10? + return [ + {name = "ICRC-7"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-7"}, + {name = "ICRC-10"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-10"}, + {name = "ICRC-37"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-37"}]; + }; + + // Marketplace functionality to sell and buy mAIners + // When a mAIner owner lists a mAIner on the marketplace for selling, the mAIner is approved for the sale and added to the listings data structures + stable var marketplaceListedMainerAgentsStorageStable : [(Text, Types.OfficialMainerAgentCanister)] = []; + var marketplaceListedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + stable var userToMarketplaceListedMainersStorageStable : [(Principal, List.List)] = []; + var userToMarketplaceListedMainersStorage : HashMap.HashMap> = HashMap.HashMap(0, Principal.equal, Principal.hash); + + // When a buyer starts the buying process of a listed mAIner, the mAIner is reserved (thus added to the data structures) and while reserved removed from the listings + stable var marketplaceReservedMainerAgentsStorageStable : [(Text, Types.OfficialMainerAgentCanister)] = []; + var marketplaceReservedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.OfficialMainerAgentCanister>)] = []; + var userToMarketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); + +// TODO: crud functions for each + private func putMainerAgentCanister(canisterAddress : Text, canisterEntry : Types.OfficialMainerAgentCanister) : Types.MainerAgentCanisterResult { + // TODO - Security: for security reasons, include checks here for an entry that already exists i.e. that immutable fields aren't changed + mainerAgentCanistersStorage.put(canisterAddress, canisterEntry); + return #Ok(canisterEntry); + }; + + private func getMainerAgentCanister(canisterAddress : Text) : ?Types.OfficialMainerAgentCanister { + switch (mainerAgentCanistersStorage.get(canisterAddress)) { + case (null) { return null; }; + case (?canisterEntry) { return ?canisterEntry; }; + }; + }; + + private func removeMainerAgentCanister(canisterAddress : Text) : Bool { + switch (mainerAgentCanistersStorage.get(canisterAddress)) { + case (null) { return false; }; + case (?canisterEntry) { + let removeResult = mainerAgentCanistersStorage.remove(canisterAddress); + // TODO: remove from userToMainerAgentsStorage + return true; + }; + }; + }; + + private func putUserMainerAgent(canisterEntry : Types.OfficialMainerAgentCanister) : Bool { + switch (getUserMainerAgents(canisterEntry.ownedBy)) { + case (null) { + // first entry + let userCanistersList : List.List = List.make(canisterEntry); + userToMainerAgentsStorage.put(canisterEntry.ownedBy, userCanistersList); + return true; + }; + case (?userCanistersList) { + //existing list, add entry to it + // Deduplicate (based on creationTimestamp) + let filteredUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.OfficialMainerAgentCanister) : Bool { listEntry.creationTimestamp != canisterEntry.creationTimestamp }); + let updatedUserCanistersList : List.List = List.push(canisterEntry, filteredUserCanistersList); + userToMainerAgentsStorage.put(canisterEntry.ownedBy, updatedUserCanistersList); + return true; + }; + }; + }; + + private func getUserMainerAgents(userId : Principal) : ?List.List { + switch (userToMainerAgentsStorage.get(userId)) { + case (null) { return null; }; + case (?userCanistersList) { return ?userCanistersList; }; + }; + }; + + // Caution: function that returns all mAIner agents (TODO: decide if needed) + private func getMainerAgents() : [Types.OfficialMainerAgentCanister] { + var mainerAgents : List.List = List.nil(); + for (userMainerAgentsList in userToMainerAgentsStorage.vals()) { + mainerAgents := List.append(userMainerAgentsList, mainerAgents); + }; + return List.toArray(mainerAgents); + }; + + private func getNumberMainerAgents(mainerType : Types.MainerAgentCanisterType) : Nat { + switch (mainerType) { + case (#Own) { + let iter = mainerAgentCanistersStorage.vals(); + let mappedIter = Iter.filter(iter, func (mainerEntry : Types.OfficialMainerAgentCanister) : Bool { + switch (mainerEntry.mainerConfig.mainerAgentCanisterType) { + case (#Own) { return true; }; + case (#ShareAgent) { return false; }; + case (_) { return false; } + }; + }); + return Iter.size(mappedIter); + }; + case (#ShareAgent) { + let iter = mainerAgentCanistersStorage.vals(); + let mappedIter = Iter.filter(iter, func (mainerEntry : Types.OfficialMainerAgentCanister) : Bool { + switch (mainerEntry.mainerConfig.mainerAgentCanisterType) { + case (#Own) { return false; }; + case (#ShareAgent) { return true; }; + case (_) { return false; } + }; + }); + return Iter.size(mappedIter); + }; + case (_) { return 0; } + }; + }; + + private func removeUserMainerAgent(canisterEntry : Types.OfficialMainerAgentCanister) : Bool { + switch (getUserMainerAgents(canisterEntry.ownedBy)) { + case (null) { return false; }; + case (?userCanistersList) { + //existing list, remove entry from it + let updatedUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.OfficialProtocolCanister) : Bool { listEntry.address != canisterEntry.address }); + userToMainerAgentsStorage.put(canisterEntry.ownedBy, updatedUserCanistersList); + return true; + }; + }; + }; + // Upgrade Hooks (TODO - Implementation: upgrade Motoko to use enhanced orthogonal persistence) system func preupgrade() { challengerCanistersStorageStable := Iter.toArray(challengerCanistersStorage.entries()); From a671a7748ece8199538dc76fbaac6255eac06ea4 Mon Sep 17 00:00:00 2001 From: patnorris Date: Tue, 4 Nov 2025 17:15:12 +0100 Subject: [PATCH 02/29] Add helper functions --- src/GameState/src/Main.mo | 132 ++++++++++++++++++++------------------ src/common/Types.mo | 10 +++ 2 files changed, 78 insertions(+), 64 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 0c1c488..13b57ec 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8298,83 +8298,85 @@ actor class GameStateCanister() = this { // Marketplace functionality to sell and buy mAIners // When a mAIner owner lists a mAIner on the marketplace for selling, the mAIner is approved for the sale and added to the listings data structures - stable var marketplaceListedMainerAgentsStorageStable : [(Text, Types.OfficialMainerAgentCanister)] = []; - var marketplaceListedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); - stable var userToMarketplaceListedMainersStorageStable : [(Principal, List.List)] = []; - var userToMarketplaceListedMainersStorage : HashMap.HashMap> = HashMap.HashMap(0, Principal.equal, Principal.hash); + stable var marketplaceListedMainerAgentsStorageStable : [(Text, Types.MainerMarketplaceListing)] = []; + var marketplaceListedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + stable var userToMarketplaceListedMainersStorageStable : [(Principal, List.List)] = []; + var userToMarketplaceListedMainersStorage : HashMap.HashMap> = HashMap.HashMap(0, Principal.equal, Principal.hash); // When a buyer starts the buying process of a listed mAIner, the mAIner is reserved (thus added to the data structures) and while reserved removed from the listings - stable var marketplaceReservedMainerAgentsStorageStable : [(Text, Types.OfficialMainerAgentCanister)] = []; - var marketplaceReservedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); - stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.OfficialMainerAgentCanister>)] = []; - var userToMarketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); - -// TODO: crud functions for each - private func putMainerAgentCanister(canisterAddress : Text, canisterEntry : Types.OfficialMainerAgentCanister) : Types.MainerAgentCanisterResult { - // TODO - Security: for security reasons, include checks here for an entry that already exists i.e. that immutable fields aren't changed - mainerAgentCanistersStorage.put(canisterAddress, canisterEntry); - return #Ok(canisterEntry); + stable var marketplaceReservedMainerAgentsStorageStable : [(Text, Types.MainerMarketplaceListing)] = []; + var marketplaceReservedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.MainerMarketplaceListing>)] = []; + var userToMarketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); + + // CRUD helper functions for listings + private func putMarketplaceListedMainer(entry : Types.MainerMarketplaceListing) : Types.MainerMarketplaceListing { + marketplaceListedMainerAgentsStorage.put(entry.address, entry); + switch (userToMarketplaceListedMainersStorage(entry.listedBy)) { + case (null) { + // first entry + let userCanistersList : List.List = List.make(entry); + userToMarketplaceListedMainersStorage.put(entry.listedBy, userCanistersList); + }; + case (?userCanistersList) { + //existing list, add entry to it + // Deduplicate (based on address) + let filteredUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.MainerMarketplaceListing) : Bool { listEntry.address != entry.address }); + let updatedUserCanistersList : List.List = List.push(entry, filteredUserCanistersList); + userToMarketplaceListedMainersStorage.put(entry.listedBy, updatedUserCanistersList); + }; + }; + return entry; }; - private func getMainerAgentCanister(canisterAddress : Text) : ?Types.OfficialMainerAgentCanister { - switch (mainerAgentCanistersStorage.get(canisterAddress)) { + private func getMarketplaceListedMainer(canisterAddress : Text) : ?Types.MainerMarketplaceListing { + switch (marketplaceListedMainerAgentsStorage.get(canisterAddress)) { case (null) { return null; }; case (?canisterEntry) { return ?canisterEntry; }; }; }; - private func removeMainerAgentCanister(canisterAddress : Text) : Bool { - switch (mainerAgentCanistersStorage.get(canisterAddress)) { + private func removeMarketplaceListedMainer(canisterAddress : Text) : Bool { + switch (marketplaceListedMainerAgentsStorage.get(canisterAddress)) { case (null) { return false; }; case (?canisterEntry) { - let removeResult = mainerAgentCanistersStorage.remove(canisterAddress); - // TODO: remove from userToMainerAgentsStorage + let removeResult = marketplaceListedMainerAgentsStorage.remove(canisterAddress); + switch (userToMarketplaceListedMainersStorage(canisterEntry.listedBy)) { + case (null) { + // this should not happen + }; + case (?userCanistersList) { + //existing list, remove entry + let filteredUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.MainerMarketplaceListing) : Bool { listEntry.address != canisterEntry.address }); + userToMarketplaceListedMainersStorage.put(entry.canisterEntry, filteredUserCanistersList); + }; + }; return true; }; }; }; - private func putUserMainerAgent(canisterEntry : Types.OfficialMainerAgentCanister) : Bool { - switch (getUserMainerAgents(canisterEntry.ownedBy)) { - case (null) { - // first entry - let userCanistersList : List.List = List.make(canisterEntry); - userToMainerAgentsStorage.put(canisterEntry.ownedBy, userCanistersList); - return true; - }; - case (?userCanistersList) { - //existing list, add entry to it - // Deduplicate (based on creationTimestamp) - let filteredUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.OfficialMainerAgentCanister) : Bool { listEntry.creationTimestamp != canisterEntry.creationTimestamp }); - let updatedUserCanistersList : List.List = List.push(canisterEntry, filteredUserCanistersList); - userToMainerAgentsStorage.put(canisterEntry.ownedBy, updatedUserCanistersList); - return true; - }; - }; - }; - - private func getUserMainerAgents(userId : Principal) : ?List.List { - switch (userToMainerAgentsStorage.get(userId)) { + private func getMarketplaceListedMainersForUser(userId : Principal) : ?List.List { + switch (userToMarketplaceListedMainersStorage.get(userId)) { case (null) { return null; }; case (?userCanistersList) { return ?userCanistersList; }; }; }; - // Caution: function that returns all mAIner agents (TODO: decide if needed) - private func getMainerAgents() : [Types.OfficialMainerAgentCanister] { - var mainerAgents : List.List = List.nil(); - for (userMainerAgentsList in userToMainerAgentsStorage.vals()) { - mainerAgents := List.append(userMainerAgentsList, mainerAgents); + private func getAllMarketplaceListedMainers() : [Types.MainerMarketplaceListing] { + var mainerAgents : List.List = List.nil(); + for (userMainerAgentsList in userToMarketplaceListedMainersStorage.vals()) { + mainerAgents := List.append(userMainerAgentsList, mainerAgents); }; return List.toArray(mainerAgents); }; - private func getNumberMainerAgents(mainerType : Types.MainerAgentCanisterType) : Nat { + private func getNumberMarketplaceListedMainers(mainerType : Types.MainerAgentCanisterType) : Nat { switch (mainerType) { case (#Own) { - let iter = mainerAgentCanistersStorage.vals(); - let mappedIter = Iter.filter(iter, func (mainerEntry : Types.OfficialMainerAgentCanister) : Bool { - switch (mainerEntry.mainerConfig.mainerAgentCanisterType) { + let iter = marketplaceListedMainerAgentsStorage.vals(); + let mappedIter = Iter.filter(iter, func (mainerEntry : Types.MainerMarketplaceListing) : Bool { + switch (mainerEntry.mainerType) { case (#Own) { return true; }; case (#ShareAgent) { return false; }; case (_) { return false; } @@ -8383,9 +8385,9 @@ actor class GameStateCanister() = this { return Iter.size(mappedIter); }; case (#ShareAgent) { - let iter = mainerAgentCanistersStorage.vals(); - let mappedIter = Iter.filter(iter, func (mainerEntry : Types.OfficialMainerAgentCanister) : Bool { - switch (mainerEntry.mainerConfig.mainerAgentCanisterType) { + let iter = marketplaceListedMainerAgentsStorage.vals(); + let mappedIter = Iter.filter(iter, func (mainerEntry : Types.MainerMarketplaceListing) : Bool { + switch (mainerEntry.mainerType) { case (#Own) { return false; }; case (#ShareAgent) { return true; }; case (_) { return false; } @@ -8397,17 +8399,7 @@ actor class GameStateCanister() = this { }; }; - private func removeUserMainerAgent(canisterEntry : Types.OfficialMainerAgentCanister) : Bool { - switch (getUserMainerAgents(canisterEntry.ownedBy)) { - case (null) { return false; }; - case (?userCanistersList) { - //existing list, remove entry from it - let updatedUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.OfficialProtocolCanister) : Bool { listEntry.address != canisterEntry.address }); - userToMainerAgentsStorage.put(canisterEntry.ownedBy, updatedUserCanistersList); - return true; - }; - }; - }; + // CRUD helper functions for reservations // Upgrade Hooks (TODO - Implementation: upgrade Motoko to use enhanced orthogonal persistence) system func preupgrade() { @@ -8427,6 +8419,10 @@ actor class GameStateCanister() = this { sharedServiceCanistersStorageStable := Iter.toArray(sharedServiceCanistersStorage.entries()); redeemedTransactionBlocksStorageStable := Iter.toArray(redeemedTransactionBlocksStorage.entries()); redeemedFunnaiTransactionBlocksStorageStable := Iter.toArray(redeemedFunnaiTransactionBlocksStorage.entries()); + marketplaceListedMainerAgentsStorageStable := Iter.toArray(marketplaceListedMainerAgentsStorage.entries()); + userToMarketplaceListedMainersStorageStable := Iter.toArray(userToMarketplaceListedMainersStorage.entries()); + marketplaceReservedMainerAgentsStorageStable := Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); + userToMarketplaceReservedMainerStorageStable := Iter.toArray(userToMarketplaceReservedMainerStorage.entries()); }; system func postupgrade() { @@ -8462,5 +8458,13 @@ actor class GameStateCanister() = this { redeemedTransactionBlocksStorageStable := []; redeemedFunnaiTransactionBlocksStorage := HashMap.fromIter(Iter.fromArray(redeemedFunnaiTransactionBlocksStorageStable), redeemedFunnaiTransactionBlocksStorageStable.size(), Nat.equal, Hash.hash); redeemedFunnaiTransactionBlocksStorageStable := []; + marketplaceListedMainerAgentsStorage := HashMap.fromIter(Iter.fromArray(marketplaceListedMainerAgentsStorageStable), marketplaceListedMainerAgentsStorageStable.size(), Text.equal, Text.hash); + marketplaceListedMainerAgentsStorageStable := []; + userToMarketplaceListedMainersStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceListedMainersStorageStable), userToMarketplaceListedMainersStorageStable.size(), Principal.equal, Principal.hash); + userToMarketplaceListedMainersStorageStable := []; + marketplaceReservedMainerAgentsStorage := HashMap.fromIter(Iter.fromArray(marketplaceReservedMainerAgentsStorageStable), marketplaceReservedMainerAgentsStorageStable.size(), Text.equal, Text.hash); + marketplaceReservedMainerAgentsStorageStable := []; + userToMarketplaceReservedMainerStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceReservedMainerStorageStable), userToMarketplaceReservedMainerStorageStable.size(), Principal.equal, Principal.hash); + userToMarketplaceReservedMainerStorageStable := []; }; }; diff --git a/src/common/Types.mo b/src/common/Types.mo index 63df14e..d77a086 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -343,6 +343,16 @@ module Types { mainerConfig : MainerConfigurationInput; }; + public type MainerMarketplaceListing = { + address : CanisterAddress; + mainerType: MainerAgentCanisterType; + listedTimestamp : Nat64; + listedBy : Principal; + priceE8S : Nat; + }; + + public type MainerMarketplaceListingsResult = Result<[MainerMarketplaceListing], ApiError>; + public type CanisterInput = { address : CanisterAddress; subnet : Text; From 7877b673a2c7520d14fe5fec0ad9d6f80bf1ca1d Mon Sep 17 00:00:00 2001 From: patnorris Date: Wed, 5 Nov 2025 17:40:16 +0100 Subject: [PATCH 03/29] Add reservation helper functions --- src/GameState/src/Main.mo | 83 +++++++++++++++++++++++++++++++++++++++ src/common/Types.mo | 1 + 2 files changed, 84 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 13b57ec..bd8196b 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8400,6 +8400,89 @@ actor class GameStateCanister() = this { }; // CRUD helper functions for reservations + private func putMarketplaceReservedMainer(entry : Types.MainerMarketplaceListing) : Bool { + // Check that entry is in listings and isn't reserved already + switch (getMarketplaceListedMainer(entry.address)) { + case (null) { + return false; + }; + case (?listedEntry) { + switch (marketplaceReservedMainerAgentsStorage.get(entry.address)) { + case (null) { + // Continue + }; + case (?reservedEntry) { + return false; + }; + }; + }; + }; + // Sanity check on entry + switch (entry.reservedBy) { + case (null) { + return false; + }; + case (?reservingUserPrincipal) { + // Reserve the mAIner and remove it from listings + marketplaceReservedMainerAgentsStorage.put(entry.address, entry); + userToMarketplaceReservedMainerStorage.put(reservingUserPrincipal, entry); + switch (removeMarketplaceListedMainer(entry.address)) { + case (true) { + // TODO: set a timer to remove the reservation and add it back as a listing (in case the purchase wasn't completed in the meanwhile) + return true; + }; + case (false) { + // Revert reservation changes + let removeResult = marketplaceReservedMainerAgentsStorage.remove(entry.address); + let removeResult2 = userToMarketplaceReservedMainerStorage.remove(reservingUserPrincipal); + return false; + }; + }; + }; + }; + }; + + private func getMarketplaceReservedMainer(canisterAddress : Text) : ?Types.MainerMarketplaceListing { + switch (marketplaceReservedMainerAgentsStorage.get(canisterAddress)) { + case (null) { return null; }; + case (?canisterEntry) { return ?canisterEntry; }; + }; + }; + + private func removeMarketplaceReservedMainer(canisterAddress : Text) : Bool { + // If the reservation (still) exists, remove it and put the entry back up as a listing + switch (marketplaceReservedMainerAgentsStorage.get(canisterAddress)) { + case (null) { return false; }; + case (?canisterEntry) { + let removeResult = marketplaceReservedMainerAgentsStorage.remove(canisterAddress); + switch (canisterEntry.reservedBy) { + case (null) { + // This should not happen + }; + case (?reservingUserPrincipal) { + let removeResult2 = userToMarketplaceReservedMainerStorage.remove(reservingUserPrincipal); + }; + }; + let newListingEntry = { + address : CanisterAddress = canisterEntry.address; + mainerType: MainerAgentCanisterType = canisterEntry.mainerType; + listedTimestamp : Nat64 = canisterEntry.listedTimestamp; + listedBy : Principal = canisterEntry.listedBy; + priceE8S : Nat = canisterEntry.priceE8S; + reservedBy : ?Principal = null; // Only change + }; + putMarketplaceListedMainer(newListingEntry); + return true; + }; + }; + }; + + private func getMarketplaceReservedMainerForUser(userId : Principal) : ?Types.MainerMarketplaceListing { + switch (userToMarketplaceReservedMainerStorage.get(userId)) { + case (null) { return null; }; + case (?userCanisterEntry) { return ?userCanisterEntry; }; + }; + }; // Upgrade Hooks (TODO - Implementation: upgrade Motoko to use enhanced orthogonal persistence) system func preupgrade() { diff --git a/src/common/Types.mo b/src/common/Types.mo index d77a086..628e971 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -349,6 +349,7 @@ module Types { listedTimestamp : Nat64; listedBy : Principal; priceE8S : Nat; + reservedBy : ?Principal; }; public type MainerMarketplaceListingsResult = Result<[MainerMarketplaceListing], ApiError>; From 85143bed1a383c1374127f4711c5bab366f72052 Mon Sep 17 00:00:00 2001 From: patnorris Date: Thu, 6 Nov 2025 14:08:43 +0100 Subject: [PATCH 04/29] Add endpoints --- src/GameState/src/Main.mo | 125 +++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index bd8196b..b63828d 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8262,11 +8262,27 @@ actor class GameStateCanister() = this { return null; // TODO: placeholder: only allow 1 token id and retrieve info for it }; - public query func icrc7_balance_of(accounts: BalanceOfRequest) : async BalanceOfResponse { - return null; // TODO: placeholder: only allow 1 token id and retrieve info for it + public query func icrc7_balance_of(accounts: [TokenLedger.Account]) : async [Nat] { + // Only allows 1 account and retrieves info for it + if (Principal.isAnonymous(msg.caller)) { + return [0]; + }; + if (accounts.size() !== 1) { + return [0]; + }; + switch (getMarketplaceListedMainersForUser(accounts[0].owner)) { + case (null) { return [0]; }; + case (?userCanistersList) { + let numberOfListings : Nat = List.size(userCanistersList); + return [numberOfListings]; + }; + }; + return null; }; public query func icrc7_tokens(prev: ?Nat, take: ?Nat) : async [Nat] { + // TODO: create a list of Nat with index (from 0 to size minus 1) via a new helper function + getAllMarketplaceListedMainers() : [Types.MainerMarketplaceListing] // retrieve actual listings info via another endpoint return []; // TODO: Retrieve all listed mAIners }; @@ -8296,7 +8312,112 @@ actor class GameStateCanister() = this { {name = "ICRC-37"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-37"}]; }; + public shared(msg) func icrc37_approve_tokens(args: [ICRC37.Service.ApproveTokenArg]) : async [?ICRC37.Service.ApproveTokenResult] { + /* type Account = record { owner : principal; subaccount : opt Subaccount }; + + type ApprovalInfo = record { + spender : Account; // Game State (no Subaccount) but doesn't need to be checked + from_subaccount : opt blob; // null + expires_at : opt nat64; // null + memo : opt blob; // mAIner address + created_at_time : nat64; // doesn't matter, canister will create a timestamp + }; + + type ApproveTokenArg = record { + token_id : nat; // price (for listing) + approval_info : ApprovalInfo; + }; */ + /* type ApproveTokenResult = variant { + Ok : nat; // Transaction index for successful approval + Err : ApproveTokenError; + }; + + type ApproveTokenError = variant { + InvalidSpender; + Unauthorized; + NonExistingTokenId; + TooOld; + CreatedInFuture : record { ledger_time: nat64 }; + GenericError : record { error_code : nat; message : text }; + GenericBatchError : record { error_code : nat; message : text }; + }; */ + // mAIner Owner lists one of their mAIners (this call only works for one mAIner, thus first entry in array args) + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + if (args.size() !== 1) { + return #Err(#Unauthorized); + }; + let approveTokenArg : ICRC37.Service.ApproveTokenArg = args[0]; + if (approveTokenArg.token_id < 1000000) { + // Price has to be at least 0.01 ICP + return #Err(#Unauthorized); + }; + // Get mAIner address from memo + switch (approveTokenArg.approval_info.memo) { + case (null) { + // No mAIner canister specified + return #Err(#Unauthorized); + }; + case (?approvalMemo) { + let text = Text.decodeUtf8(approvalMemo); + switch (text) { + case (null) { + // No mAIner canister specified + return #Err(#Unauthorized); + }; + case (?mainerAddress) { + // Confirm caller owns mAIner + switch (getUserMainerAgents(msg.caller)) { + case (null) { + return #Err(#Unauthorized); + }; + case (?userMainerEntries) { + switch (List.find(userMainerEntries, func(mainerEntry: Types.OfficialMainerAgentCanister) : Bool { mainerEntry.address == mainerAddress } )) { + case (null) { + return #Err(#NonExistingTokenId); + }; + case (?userMainerEntry) { + // Sanity checks on userMainerEntry (i.e. address provided is correct and matches entry info) + switch (userMainerEntry.canisterType) { + case (#MainerAgent(mainerAgentCanisterType)) { + // Check that mAIner is not reserved currently + switch (getMarketplaceReservedMainer(mainerAddress)) { + case (?canisterEntry) { return #Err(#Unauthorized); }; // The mAIner is currently reserved and thus in the process of being bought + case (null) { + // Add mAIner to listings + let entry : Types.MainerMarketplaceListing = { + address : CanisterAddress = userMainerEntry.address; + mainerType: MainerAgentCanisterType = mainerAgentCanisterType; + listedTimestamp : Nat64 = Nat64.fromNat(Int.abs(Time.now())); + listedBy : Principal = msg.caller; + priceE8S : Nat = approveTokenArg.token_id; + reservedBy : ?Principal = null; + }; + let result = putMarketplaceListedMainer(entry); + return #Ok(getNextMainerMarketplaceTransactionId()); + }; + }; + }; + case (_) { return #Err(#Unauthorized); } + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + // Marketplace functionality to sell and buy mAIners + stable var mainerMarketplaceTransactionsCounter : Nat = 0; + private func getNextMainerMarketplaceTransactionId() : Nat { + mainerMarketplaceTransactionsCounter = mainerMarketplaceTransactionsCounter + 1; + return mainerMarketplaceTransactionsCounter; + }; + // When a mAIner owner lists a mAIner on the marketplace for selling, the mAIner is approved for the sale and added to the listings data structures stable var marketplaceListedMainerAgentsStorageStable : [(Text, Types.MainerMarketplaceListing)] = []; var marketplaceListedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); From cf31cc29032238778db7374b7a7f37a2600ac0e7 Mon Sep 17 00:00:00 2001 From: patnorris Date: Thu, 6 Nov 2025 18:02:56 +0100 Subject: [PATCH 05/29] Add needed functions --- src/GameState/src/Main.mo | 60 ++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index b63828d..d62c190 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8193,7 +8193,7 @@ actor class GameStateCanister() = this { return ?icrc7Logo; }; - public query func icrc7_max_memo_size() : async ?Nat { + /* public query func icrc7_max_memo_size() : async ?Nat { return ?100; // TODO: placeholder }; @@ -8203,10 +8203,10 @@ actor class GameStateCanister() = this { public query func icrc7_permitted_drift() : async ?Nat { return ?100; // TODO: placeholder - }; + }; */ public query func icrc7_total_supply() : async Nat { - return icrc7().get_stats().nft_count; // TODO: number of listings + return marketplaceListedMainerAgentsStorage.size(); }; public query func icrc7_supply_cap() : async ?Nat { @@ -8214,7 +8214,7 @@ actor class GameStateCanister() = this { return ?currentNumberOfMainers; }; - public query func icrc37_max_approvals_per_token_or_collection() : async ?Nat { + /* public query func icrc37_max_approvals_per_token_or_collection() : async ?Nat { return ?1; // TODO: placeholder }; @@ -8240,7 +8240,7 @@ actor class GameStateCanister() = this { public query func icrc37_max_revoke_approvals() : async ?Nat { return ?1; // TODO: placeholder - }; + }; */ public query func icrc7_collection_metadata() : async [(Text, Value)] { let metadata : [(Text, Value)] = [ @@ -8253,14 +8253,9 @@ actor class GameStateCanister() = this { return metadata; }; - - public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, Value)]]{ + /* public query func icrc7_owner_of(token_ids: OwnerOfRequest) : async OwnerOfResponse { return null; // TODO: placeholder: only allow 1 token id and retrieve info for it - }; - - public query func icrc7_owner_of(token_ids: OwnerOfRequest) : async OwnerOfResponse { - return null; // TODO: placeholder: only allow 1 token id and retrieve info for it - }; + }; */ public query func icrc7_balance_of(accounts: [TokenLedger.Account]) : async [Nat] { // Only allows 1 account and retrieves info for it @@ -8277,22 +8272,47 @@ actor class GameStateCanister() = this { return [numberOfListings]; }; }; - return null; }; public query func icrc7_tokens(prev: ?Nat, take: ?Nat) : async [Nat] { - // TODO: create a list of Nat with index (from 0 to size minus 1) via a new helper function - getAllMarketplaceListedMainers() : [Types.MainerMarketplaceListing] // retrieve actual listings info via another endpoint - return []; // TODO: Retrieve all listed mAIners + // Create a list of Nat with entries from 0 to marketplaceListedMainerAgentsStorage's size minus 1 + let total = marketplaceListedMainerAgentsStorage.size(); + if (total == 0) { + return []; + }; + + var ids : List.List = List.nil(); + for (i in Iter.range(0, total - 1)) { + ids := List.push(i, ids); + i += 1; + }; + let idsArray = Array.fromList(ids); + + return idsArray; }; - public query func icrc7_tokens_of(account: Account, prev: ?Nat, take: ?Nat) : async [Nat] { - return []; // TODO: Retrieve all listed mAIners for account + public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, Value)]]{ + // Retrieve all mAIner marketplace listings + // TODO: getAllMarketplaceListedMainers() : [Types.MainerMarketplaceListing] }; - public query func icrc37_is_approved(args: [IsApprovedArg]) : async [Bool] { + /* public query func icrc7_tokens_of(account: Account, prev: ?Nat, take: ?Nat) : async [Nat] { + // Retrieve all listed mAIners for account + if (Principal.isAnonymous(msg.caller)) { + return [0]; + }; + switch (getMarketplaceListedMainersForUser(account.owner)) { + case (null) { return [0]; }; + case (?userCanistersList) { + let numberOfListings : Nat = List.size(userCanistersList); + return [numberOfListings]; + }; + }; + }; */ + + /* public query func icrc37_is_approved(args: [IsApprovedArg]) : async [Bool] { return [false]; // TODO: only allow 1 token id and check if listed - }; + }; */ /* public query func icrc37_get_token_approvals(token_ids: [Nat], prev: ?TokenApproval, take: ?Nat) : async [TokenApproval] { From 9123cf04ba9fc77ebdfe68c3a2bb02b4297b55c6 Mon Sep 17 00:00:00 2001 From: patnorris Date: Fri, 7 Nov 2025 16:56:11 +0100 Subject: [PATCH 06/29] Add marketplace endpoints --- src/GameState/mops.toml | 3 + src/GameState/src/Main.mo | 331 ++++++++++++++++++++++++++++++++++++-- src/common/Types.mo | 6 + 3 files changed, 326 insertions(+), 14 deletions(-) diff --git a/src/GameState/mops.toml b/src/GameState/mops.toml index 373589f..5251c03 100644 --- a/src/GameState/mops.toml +++ b/src/GameState/mops.toml @@ -1,3 +1,6 @@ [dependencies] base = "0.13.5" uuid = "https://github.com/aviate-labs/uuid.mo#v0.2.0" +icrc3-mo = "0.2.4" +icrc37-mo = "0.5.1" +icrc7-mo = "0.5.0" diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index d62c190..9a191c9 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -19,6 +19,10 @@ import Array "mo:base/Array"; import { setTimer; recurringTimer } = "mo:base/Timer"; import Timer "mo:base/Timer"; +import ICRC7 "mo:icrc7-mo"; +import ICRC37 "mo:icrc37-mo"; +import ICRC3 "mo:icrc3-mo"; + import Types "../../common/Types"; import ICManagementCanister "../../common/ICManagementCanister"; import TokenLedger "../../common/icp-ledger-interface"; @@ -8262,7 +8266,7 @@ actor class GameStateCanister() = this { if (Principal.isAnonymous(msg.caller)) { return [0]; }; - if (accounts.size() !== 1) { + if (accounts.size() != 1) { return [0]; }; switch (getMarketplaceListedMainersForUser(accounts[0].owner)) { @@ -8293,7 +8297,23 @@ actor class GameStateCanister() = this { public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, Value)]]{ // Retrieve all mAIner marketplace listings - // TODO: getAllMarketplaceListedMainers() : [Types.MainerMarketplaceListing] + let listings : [Types.MainerMarketplaceListing] = getAllMarketplaceListedMainers(); + // Convert each listing NFT metadata (array which contains the (Text, Value) pairs) + let out : [?[(Text, Value)]] = Array.map( + listings, + func (l : Types.MainerMarketplaceListing) : ?[(Text, Value)] { + let meta : [(Text, Value)] = [ + ("address", #Text(l.address)), + ("mainerType", #Text(debug_show(l.mainerType))), + ("listedTimestamp", #Nat64(l.listedTimestamp)), + ("listedBy", #Text(Principal.toText(l.listedBy))), + ("priceE8S", #Nat(l.priceE8S)), + ]; + ?meta + } + ); + + return out; }; /* public query func icrc7_tokens_of(account: Account, prev: ?Nat, take: ?Nat) : async [Nat] { @@ -8363,39 +8383,39 @@ actor class GameStateCanister() = this { }; */ // mAIner Owner lists one of their mAIners (this call only works for one mAIner, thus first entry in array args) if (Principal.isAnonymous(msg.caller)) { - return #Err(#Unauthorized); + return [?#Err(#Unauthorized)]; }; - if (args.size() !== 1) { - return #Err(#Unauthorized); + if (args.size() != 1) { + return [?#Err(#Unauthorized)]; }; let approveTokenArg : ICRC37.Service.ApproveTokenArg = args[0]; if (approveTokenArg.token_id < 1000000) { // Price has to be at least 0.01 ICP - return #Err(#Unauthorized); + return [?#Err(#Unauthorized)]; }; // Get mAIner address from memo switch (approveTokenArg.approval_info.memo) { case (null) { // No mAIner canister specified - return #Err(#Unauthorized); + return [?#Err(#Unauthorized)]; }; case (?approvalMemo) { let text = Text.decodeUtf8(approvalMemo); switch (text) { case (null) { // No mAIner canister specified - return #Err(#Unauthorized); + return [?#Err(#Unauthorized)]; }; case (?mainerAddress) { // Confirm caller owns mAIner switch (getUserMainerAgents(msg.caller)) { case (null) { - return #Err(#Unauthorized); + return [?#Err(#Unauthorized)]; }; case (?userMainerEntries) { switch (List.find(userMainerEntries, func(mainerEntry: Types.OfficialMainerAgentCanister) : Bool { mainerEntry.address == mainerAddress } )) { case (null) { - return #Err(#NonExistingTokenId); + return [?#Err(#NonExistingTokenId)]; }; case (?userMainerEntry) { // Sanity checks on userMainerEntry (i.e. address provided is correct and matches entry info) @@ -8415,11 +8435,94 @@ actor class GameStateCanister() = this { reservedBy : ?Principal = null; }; let result = putMarketplaceListedMainer(entry); - return #Ok(getNextMainerMarketplaceTransactionId()); + return [?#Ok(getNextMainerMarketplaceTransactionId())]; + }; + }; + }; + case (_) { return [?#Err(#Unauthorized)]; } + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + + public shared(msg) func icrc37_revoke_token_approvals(args: [ICRC37.Service.RevokeTokenApprovalArg]) : async [?RevokeTokenApprovalResult] { + /* type RevokeTokenApprovalArg = record { + spender : opt Account; // null revokes matching approvals for all spenders + from_subaccount : opt blob; // null refers to the default subaccount + token_id : nat; + memo : opt blob; // only field that matters: use for mAIner's id (canister address) + created_at_time : opt nat64; + }; + + type RevokeTokenApprovalResponse = variant { + Ok : nat; // Transaction index for successful approval revocation + Err : RevokeTokenApprovalError; + }; + + type RevokeTokenApprovalError = variant { + ApprovalDoesNotExist; + Unauthorized; + NonExistingTokenId; + TooOld; + CreatedInFuture : record { ledger_time: nat64 }; + GenericError : record { error_code : nat; message : text }; + GenericBatchError : record { error_code : nat; message : text }; + }; */ + if (Principal.isAnonymous(msg.caller)) { + return [?#Err(#Unauthorized)]; + }; + if (args.size() != 1) { + return [?#Err(#Unauthorized)]; + }; + let revokeTokenArg : ICRC37.Service.RevokeTokenApprovalArg = args[0]; + // Get mAIner address from memo + switch (revokeTokenArg.memo) { + case (null) { + // No mAIner canister specified + return [?#Err(#Unauthorized)]; + }; + case (?revokeMemo) { + let text = Text.decodeUtf8(revokeMemo); + switch (text) { + case (null) { + // No mAIner canister specified + return [?#Err(#Unauthorized)]; + }; + case (?mainerAddress) { + // Confirm caller owns mAIner + switch (getUserMainerAgents(msg.caller)) { + case (null) { + return [?#Err(#Unauthorized)]; + }; + case (?userMainerEntries) { + switch (List.find(userMainerEntries, func(mainerEntry: Types.OfficialMainerAgentCanister) : Bool { mainerEntry.address == mainerAddress } )) { + case (null) { + return [?#Err(#Unauthorized)]; + }; + case (?userMainerEntry) { + switch (userMainerEntry.canisterType) { + case (#MainerAgent(mainerAgentCanisterType)) { + // Check that mAIner is listed currently + switch (getMarketplaceListedMainer(mainerAddress)) { + case (null) { return [?#Err(#Unauthorized)]; }; + case (?canisterEntry) { + // Remove mAIner from listings + switch (removeMarketplaceListedMainer(mainerAddress)) { + case (false) { return [?#Err(#Unauthorized)]; }; + case (true) { + return [?#Ok(getNextMainerMarketplaceTransactionId())]; + }; + }; }; }; }; - case (_) { return #Err(#Unauthorized); } + case (_) { return [?#Err(#Unauthorized)]; } }; }; }; @@ -8431,10 +8534,136 @@ actor class GameStateCanister() = this { }; }; + public shared(msg) func icrc37_transfer_from(args: [ICRC37.Service.TransferFromArg]) : async [?ICRC37.Service.TransferFromResult] { + /* type TransferFromArg = record { + spender_subaccount: opt blob; // The subaccount of the caller (used to identify the spender) + from : Account; + to : Account; + token_id : nat; // Used as ICP payment transaction id + memo : opt blob; // Used as mAIner's address + created_at_time : opt nat64; + }; + + type TransferFromResult = variant { + Ok : nat; // Transaction index for successful transfer + Err : TransferFromError; + }; + + type TransferFromError = variant { + InvalidRecipient; + Unauthorized; + NonExistingTokenId; + TooOld; + CreatedInFuture : record { ledger_time: nat64 }; + Duplicate : record { duplicate_of : nat }; + GenericError : record { error_code : nat; message : text }; + GenericBatchError : record { error_code : nat; message : text }; + }; */ + if (Principal.isAnonymous(msg.caller)) { + return [?#Err(#Unauthorized)]; + }; + if (args.size() != 1) { + return [?#Err(#Unauthorized)]; + }; + let transferTokenArg : ICRC37.Service.TransferFromArg = args[0]; + switch (transferTokenArg.memo) { + case (null) { + // No mAIner canister specified + return [?#Err(#Unauthorized)]; + }; + case (?transferMemo) { + let text = Text.decodeUtf8(transferMemo); + switch (text) { + case (null) { + // No mAIner canister specified + return [?#Err(#Unauthorized)]; + }; + case (?mainerAddress) { + let transactionToVerify = Nat64.fromNat(transferTokenArg.token_id); + switch (checkExistingTransactionBlock(transactionToVerify)) { + case (false) { + // new transaction, continue + }; + case (true) { + // already redeem transaction + return [?#Err(#Unauthorized)]; // no double spending + }; + }; + // Verify that caller has reserved the mAIner + switch (getMarketplaceReservedMainerForUser(msg.caller)) { + case (null) { return [?#Err(#Unauthorized)]; }; + case (?userCanisterEntry) { + if (userCanisterEntry.address != mainerAddress) { + return [?#Err(#Unauthorized)]; + }; + switch (getMainerAgentCanister(mainerAddress)) { + case (null) { return [?#Err(#InvalidRecipient)]; }; + case (?mainerEntry) { + // TODO: Verify user's payment for this agent via the TransactionBlockId (incl. correct price) + /* var verifiedPayment : Bool = false; + var amountPaid : Nat = 0; + let redeemedFor : Types.RedeemedForOptions = #MainerTopUp(userMainerEntry.address); + let creationTimestamp : Nat64 = Nat64.fromNat(Int.abs(Time.now())); + let transactionEntryToVerify : Types.RedeemedTransactionBlock = { + paymentTransactionBlockId : Nat64 = transactionToVerify; + creationTimestamp : Nat64 = creationTimestamp; + redeemedBy : Principal = msg.caller; + redeemedFor : Types.RedeemedForOptions = redeemedFor; + amount : Nat = amountPaid; // to be updated + }; + D.print("GameState: icrc37_transfer_from - transactionEntryToVerify: "# debug_show(transactionEntryToVerify)); + let verificationResponse = await verifyIncomingPayment(transactionEntryToVerify); + D.print("GameState: icrc37_transfer_from - verificationResponse: "# debug_show(verificationResponse)); + switch (verificationResponse) { + case (#Ok(verificationResult)) { + verifiedPayment := verificationResult.verified; + amountPaid := verificationResult.amountPaid; + }; + case (_) { + return #Err(#Other("Payment verification failed")); + }; + }; + if (not verifiedPayment) { + return #Err(#Other("Payment couldn't be verified")); + }; */ + + // TODO: take protocol cut (10%) and send rest to seller + + // Transfer mAIner ownership + // Update mAIner entry + let newCanisterEntry : Types.OfficialMainerAgentCanister = { + address : Text = mainerEntry.address; + subnet : Text = mainerEntry.subnet; + canisterType: Types.ProtocolCanisterType = mainerEntry.canisterType; + creationTimestamp : Nat64 = mainerEntry.creationTimestamp; + createdBy : Principal = mainerEntry.createdBy; + ownedBy : Principal = msg.caller; // only field updated: to new owner + status : Types.CanisterStatus = mainerEntry.status; + mainerConfig : Types.MainerConfigurationInput = mainerEntry.mainerConfig; + }; + let updateResult : Types.MainerAgentCanisterResult = putMainerAgentCanister(mainerAddress, newCanisterEntry); + + // Remove from seller + let removeResult : Bool = removeUserMainerAgent(mainerEntry); + + // Add to buyer + let addResult : Bool = putUserMainerAgent(newCanisterEntry); + + return [?#Ok(getNextMainerMarketplaceTransactionId())]; + }; + }; + }; + }; + }; + }; + }; + }; + }; + // Marketplace functionality to sell and buy mAIners stable var mainerMarketplaceTransactionsCounter : Nat = 0; private func getNextMainerMarketplaceTransactionId() : Nat { - mainerMarketplaceTransactionsCounter = mainerMarketplaceTransactionsCounter + 1; + mainerMarketplaceTransactionsCounter := mainerMarketplaceTransactionsCounter + 1; return mainerMarketplaceTransactionsCounter; }; @@ -8447,7 +8676,7 @@ actor class GameStateCanister() = this { // When a buyer starts the buying process of a listed mAIner, the mAIner is reserved (thus added to the data structures) and while reserved removed from the listings stable var marketplaceReservedMainerAgentsStorageStable : [(Text, Types.MainerMarketplaceListing)] = []; var marketplaceReservedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); - stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.MainerMarketplaceListing>)] = []; + stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.MainerMarketplaceListing)] = []; var userToMarketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); // CRUD helper functions for listings @@ -8625,6 +8854,80 @@ actor class GameStateCanister() = this { }; }; + // Native mAIner marketplace endpoints (not NFT compatible) + public query (msg) func getUserMarketplaceMainerListings() : async Types.MainerMarketplaceListingsResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + switch (getMarketplaceListedMainersForUser(msg.caller)) { + case (null) { return #Ok([]); }; + case (?userCanistersList) { + return #Ok(List.toArray(userCanistersList)); + }; + }; + }; + + public query (msg) func getMarketplaceMainerListings() : async Types.MainerMarketplaceListingsResult { + // Retrieve all mAIner marketplace listings + return #Ok(getAllMarketplaceListedMainers()); + }; + + public shared (msg) func reserveMarketplaceListedMainer(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.MainerMarketplaceReservationResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + // Verify user doesn't have a reservation yet + switch (getMarketplaceReservedMainerForUser(msg.caller)) { + case (null) { + // Continue + }; + case (?userCanisterEntry) { return #Err(#Unauthorized); }; + }; + + // Verify mAIner is not reserved + switch (getMarketplaceReservedMainer(reservationInput.address)) { + case (null) { + // Continue + }; + case (?canisterEntry) { return #Err(#Unauthorized); }; + }; + + // Verify mAIner is listed + switch (getMarketplaceListedMainer(reservationInput.address)) { + case (null) { return #Err(#Unauthorized); }; + case (?canisterEntry) { + // Reserve mAIner for buying (incl. removing listing during purchase completion) + let newEntry : Types.MainerMarketplaceListing = { + address : CanisterAddress = canisterEntry.address; + mainerType: MainerAgentCanisterType = canisterEntry.mainerType; + listedTimestamp : Nat64 = canisterEntry.listedTimestamp; + listedBy : Principal = canisterEntry.listedBy; + priceE8S : Nat = canisterEntry.priceE8S; + reservedBy : ?Principal = ?msg.caller; // Only change + }; + putMarketplaceReservedMainer(newEntry); + return #Ok(newEntry); + }; + }; + }; + + public query (msg) func confirmUserMarketplaceMainerReservation(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.MainerMarketplaceReservationResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + switch (getMarketplaceReservedMainerForUser(msg.caller)) { + case (null) { + return #Err(#Unauthorized); + }; + case (?userCanisterEntry) { + if (userCanisterEntry.address == reservationInput.address) { + return #Ok(userCanisterEntry); + }; + return #Err(#Unauthorized); + }; + }; + }; + // Upgrade Hooks (TODO - Implementation: upgrade Motoko to use enhanced orthogonal persistence) system func preupgrade() { challengerCanistersStorageStable := Iter.toArray(challengerCanistersStorage.entries()); diff --git a/src/common/Types.mo b/src/common/Types.mo index 628e971..3219520 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -354,6 +354,12 @@ module Types { public type MainerMarketplaceListingsResult = Result<[MainerMarketplaceListing], ApiError>; + public type MainerMarketplaceReservationInput = { + address : CanisterAddress; + }; + + public type MainerMarketplaceReservationResult = Result; + public type CanisterInput = { address : CanisterAddress; subnet : Text; From 98259524825c6e3cebecd180b8116f08c0186820 Mon Sep 17 00:00:00 2001 From: patnorris Date: Mon, 10 Nov 2025 14:19:14 +0100 Subject: [PATCH 07/29] Fix errors --- src/GameState/src/Main.mo | 49 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 9a191c9..b211ee2 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8214,7 +8214,7 @@ actor class GameStateCanister() = this { }; public query func icrc7_supply_cap() : async ?Nat { - let currentNumberOfMainers = getNumberMainerAgents({ mainerType : Types.MainerAgentCanisterType = #ShareAgent; }); + let currentNumberOfMainers = getNumberMainerAgents(#ShareAgent); return ?currentNumberOfMainers; }; @@ -8246,8 +8246,8 @@ actor class GameStateCanister() = this { return ?1; // TODO: placeholder }; */ - public query func icrc7_collection_metadata() : async [(Text, Value)] { - let metadata : [(Text, Value)] = [ + public query func icrc7_collection_metadata() : async [(Text, ICRC7.Value)] { + let metadata : [(Text, ICRC7.Value)] = [ ("ICRC-7:Symbol", #Text(icrc7Symbol)), ("ICRC-7:Name", #Text(icrc7Name)), ("ICRC-7:Description", #Text(icrc7Description)), @@ -8261,7 +8261,7 @@ actor class GameStateCanister() = this { return null; // TODO: placeholder: only allow 1 token id and retrieve info for it }; */ - public query func icrc7_balance_of(accounts: [TokenLedger.Account]) : async [Nat] { + public query (msg) func icrc7_balance_of(accounts: [TokenLedger.Account]) : async [Nat] { // Only allows 1 account and retrieves info for it if (Principal.isAnonymous(msg.caller)) { return [0]; @@ -8287,25 +8287,24 @@ actor class GameStateCanister() = this { var ids : List.List = List.nil(); for (i in Iter.range(0, total - 1)) { - ids := List.push(i, ids); - i += 1; + ids := List.push(i, ids); }; - let idsArray = Array.fromList(ids); + let idsArray = List.toArray(ids); return idsArray; }; - public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, Value)]]{ + public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, ICRC7.Value)]]{ // Retrieve all mAIner marketplace listings let listings : [Types.MainerMarketplaceListing] = getAllMarketplaceListedMainers(); // Convert each listing NFT metadata (array which contains the (Text, Value) pairs) - let out : [?[(Text, Value)]] = Array.map( + let out : [?[(Text, ICRC7.Value)]] = Array.map( listings, - func (l : Types.MainerMarketplaceListing) : ?[(Text, Value)] { - let meta : [(Text, Value)] = [ + func (l : Types.MainerMarketplaceListing) : ?[(Text, ICRC7.Value)] { + let meta : [(Text, ICRC7.Value)] = [ ("address", #Text(l.address)), ("mainerType", #Text(debug_show(l.mainerType))), - ("listedTimestamp", #Nat64(l.listedTimestamp)), + ("listedTimestamp", #Nat(Nat64.toNat(l.listedTimestamp))), ("listedBy", #Text(Principal.toText(l.listedBy))), ("priceE8S", #Nat(l.priceE8S)), ]; @@ -8423,12 +8422,12 @@ actor class GameStateCanister() = this { case (#MainerAgent(mainerAgentCanisterType)) { // Check that mAIner is not reserved currently switch (getMarketplaceReservedMainer(mainerAddress)) { - case (?canisterEntry) { return #Err(#Unauthorized); }; // The mAIner is currently reserved and thus in the process of being bought + case (?canisterEntry) { return [?#Err(#Unauthorized)]; }; // The mAIner is currently reserved and thus in the process of being bought case (null) { // Add mAIner to listings let entry : Types.MainerMarketplaceListing = { - address : CanisterAddress = userMainerEntry.address; - mainerType: MainerAgentCanisterType = mainerAgentCanisterType; + address : Types.CanisterAddress = userMainerEntry.address; + mainerType: Types.MainerAgentCanisterType = mainerAgentCanisterType; listedTimestamp : Nat64 = Nat64.fromNat(Int.abs(Time.now())); listedBy : Principal = msg.caller; priceE8S : Nat = approveTokenArg.token_id; @@ -8451,7 +8450,7 @@ actor class GameStateCanister() = this { }; }; - public shared(msg) func icrc37_revoke_token_approvals(args: [ICRC37.Service.RevokeTokenApprovalArg]) : async [?RevokeTokenApprovalResult] { + public shared(msg) func icrc37_revoke_token_approvals(args: [ICRC37.Service.RevokeTokenApprovalArg]) : async [?ICRC37.Service.RevokeTokenApprovalResult] { /* type RevokeTokenApprovalArg = record { spender : opt Account; // null revokes matching approvals for all spenders from_subaccount : opt blob; // null refers to the default subaccount @@ -8682,7 +8681,7 @@ actor class GameStateCanister() = this { // CRUD helper functions for listings private func putMarketplaceListedMainer(entry : Types.MainerMarketplaceListing) : Types.MainerMarketplaceListing { marketplaceListedMainerAgentsStorage.put(entry.address, entry); - switch (userToMarketplaceListedMainersStorage(entry.listedBy)) { + switch (getMarketplaceListedMainersForUser(entry.listedBy)) { case (null) { // first entry let userCanistersList : List.List = List.make(entry); @@ -8711,14 +8710,14 @@ actor class GameStateCanister() = this { case (null) { return false; }; case (?canisterEntry) { let removeResult = marketplaceListedMainerAgentsStorage.remove(canisterAddress); - switch (userToMarketplaceListedMainersStorage(canisterEntry.listedBy)) { + switch (getMarketplaceListedMainersForUser(canisterEntry.listedBy)) { case (null) { // this should not happen }; case (?userCanistersList) { //existing list, remove entry let filteredUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.MainerMarketplaceListing) : Bool { listEntry.address != canisterEntry.address }); - userToMarketplaceListedMainersStorage.put(entry.canisterEntry, filteredUserCanistersList); + let result = userToMarketplaceListedMainersStorage.put(canisterEntry.listedBy, filteredUserCanistersList); }; }; return true; @@ -8834,14 +8833,14 @@ actor class GameStateCanister() = this { }; }; let newListingEntry = { - address : CanisterAddress = canisterEntry.address; - mainerType: MainerAgentCanisterType = canisterEntry.mainerType; + address : Types.CanisterAddress = canisterEntry.address; + mainerType: Types.MainerAgentCanisterType = canisterEntry.mainerType; listedTimestamp : Nat64 = canisterEntry.listedTimestamp; listedBy : Principal = canisterEntry.listedBy; priceE8S : Nat = canisterEntry.priceE8S; reservedBy : ?Principal = null; // Only change }; - putMarketplaceListedMainer(newListingEntry); + let result = putMarketplaceListedMainer(newListingEntry); return true; }; }; @@ -8898,14 +8897,14 @@ actor class GameStateCanister() = this { case (?canisterEntry) { // Reserve mAIner for buying (incl. removing listing during purchase completion) let newEntry : Types.MainerMarketplaceListing = { - address : CanisterAddress = canisterEntry.address; - mainerType: MainerAgentCanisterType = canisterEntry.mainerType; + address : Types.CanisterAddress = canisterEntry.address; + mainerType: Types.MainerAgentCanisterType = canisterEntry.mainerType; listedTimestamp : Nat64 = canisterEntry.listedTimestamp; listedBy : Principal = canisterEntry.listedBy; priceE8S : Nat = canisterEntry.priceE8S; reservedBy : ?Principal = ?msg.caller; // Only change }; - putMarketplaceReservedMainer(newEntry); + let result = putMarketplaceReservedMainer(newEntry); return #Ok(newEntry); }; }; From 506d6ae86e189e8cbbc49ad8a142b0524c86a3c4 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Tue, 11 Nov 2025 10:48:51 +0000 Subject: [PATCH 08/29] add timer --- src/GameState/src/Main.mo | 84 ++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index b211ee2..dc5c303 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8648,6 +8648,17 @@ actor class GameStateCanister() = this { // Add to buyer let addResult : Bool = putUserMainerAgent(newCanisterEntry); + // Clean up reservation and cancel the timer + switch (marketplaceReservationTimers.get(mainerAddress)) { + case (?timerId) { + Timer.cancelTimer(timerId); + ignore marketplaceReservationTimers.remove(mainerAddress); + }; + case (null) {}; + }; + ignore marketplaceReservedMainerAgentsStorage.remove(mainerAddress); + ignore marketplaceReservedMainerStorage.remove(msg.caller); + return [?#Ok(getNextMainerMarketplaceTransactionId())]; }; }; @@ -8676,7 +8687,11 @@ actor class GameStateCanister() = this { stable var marketplaceReservedMainerAgentsStorageStable : [(Text, Types.MainerMarketplaceListing)] = []; var marketplaceReservedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.MainerMarketplaceListing)] = []; - var userToMarketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); + var marketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); + + // Non-stable: Timer IDs for marketplace reservations (2 minute expiry) + var marketplaceReservationTimers : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + let MARKETPLACE_RESERVATION_TIMEOUT_SECONDS : Nat = 120; // 2 minutes // CRUD helper functions for listings private func putMarketplaceListedMainer(entry : Types.MainerMarketplaceListing) : Types.MainerMarketplaceListing { @@ -8769,7 +8784,7 @@ actor class GameStateCanister() = this { }; // CRUD helper functions for reservations - private func putMarketplaceReservedMainer(entry : Types.MainerMarketplaceListing) : Bool { + private func putMarketplaceReservedMainer(entry : Types.MainerMarketplaceListing) : Bool { // Check that entry is in listings and isn't reserved already switch (getMarketplaceListedMainer(entry.address)) { case (null) { @@ -8794,16 +8809,26 @@ actor class GameStateCanister() = this { case (?reservingUserPrincipal) { // Reserve the mAIner and remove it from listings marketplaceReservedMainerAgentsStorage.put(entry.address, entry); - userToMarketplaceReservedMainerStorage.put(reservingUserPrincipal, entry); + marketplaceReservedMainerStorage.put(reservingUserPrincipal, entry); switch (removeMarketplaceListedMainer(entry.address)) { case (true) { - // TODO: set a timer to remove the reservation and add it back as a listing (in case the purchase wasn't completed in the meanwhile) + // Set a timer to automatically unreserve if purchase isn't completed within timeout period + let timerId = Timer.setTimer( + #seconds MARKETPLACE_RESERVATION_TIMEOUT_SECONDS, + func () : async () { + // Timer expired - unreserve the mAIner and put it back as a listing + ignore removeMarketplaceReservedMainer(entry.address); + // Clean up timer reference + ignore marketplaceReservationTimers.remove(entry.address); + } + ); + marketplaceReservationTimers.put(entry.address, timerId); return true; }; case (false) { // Revert reservation changes let removeResult = marketplaceReservedMainerAgentsStorage.remove(entry.address); - let removeResult2 = userToMarketplaceReservedMainerStorage.remove(reservingUserPrincipal); + let removeResult2 = marketplaceReservedMainerStorage.remove(reservingUserPrincipal); return false; }; }; @@ -8823,13 +8848,22 @@ actor class GameStateCanister() = this { switch (marketplaceReservedMainerAgentsStorage.get(canisterAddress)) { case (null) { return false; }; case (?canisterEntry) { + // Cancel the reservation timer if it exists + switch (marketplaceReservationTimers.get(canisterAddress)) { + case (?timerId) { + Timer.cancelTimer(timerId); + ignore marketplaceReservationTimers.remove(canisterAddress); + }; + case (null) {}; + }; + let removeResult = marketplaceReservedMainerAgentsStorage.remove(canisterAddress); switch (canisterEntry.reservedBy) { case (null) { // This should not happen }; case (?reservingUserPrincipal) { - let removeResult2 = userToMarketplaceReservedMainerStorage.remove(reservingUserPrincipal); + let removeResult2 = marketplaceReservedMainerStorage.remove(reservingUserPrincipal); }; }; let newListingEntry = { @@ -8847,7 +8881,7 @@ actor class GameStateCanister() = this { }; private func getMarketplaceReservedMainerForUser(userId : Principal) : ?Types.MainerMarketplaceListing { - switch (userToMarketplaceReservedMainerStorage.get(userId)) { + switch (marketplaceReservedMainerStorage.get(userId)) { case (null) { return null; }; case (?userCanisterEntry) { return ?userCanisterEntry; }; }; @@ -8871,7 +8905,7 @@ actor class GameStateCanister() = this { return #Ok(getAllMarketplaceListedMainers()); }; - public shared (msg) func reserveMarketplaceListedMainer(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.MainerMarketplaceReservationResult { + public shared (msg) func reserveMarketplaceListedMainer(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.MainerMarketplaceReservationResult { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); }; @@ -8927,6 +8961,36 @@ actor class GameStateCanister() = this { }; }; + public shared (msg) func cancelMarketplaceReservation(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.StatusCodeRecordResult { + // Allow the original seller to cancel a stuck reservation and relist their mAIner + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + + // Check if mAIner is reserved + switch (getMarketplaceReservedMainer(reservationInput.address)) { + case (null) { + return #Err(#Unauthorized); // Not reserved + }; + case (?reservedEntry) { + // Only the original seller can cancel the reservation + if (reservedEntry.listedBy != msg.caller) { + return #Err(#Unauthorized); + }; + + // Remove the reservation and put it back as a listing + switch (removeMarketplaceReservedMainer(reservationInput.address)) { + case (true) { + return #Ok({ status_code = 200; }); + }; + case (false) { + return #Err(#Unauthorized); + }; + }; + }; + }; + }; + // Upgrade Hooks (TODO - Implementation: upgrade Motoko to use enhanced orthogonal persistence) system func preupgrade() { challengerCanistersStorageStable := Iter.toArray(challengerCanistersStorage.entries()); @@ -8948,7 +9012,7 @@ actor class GameStateCanister() = this { marketplaceListedMainerAgentsStorageStable := Iter.toArray(marketplaceListedMainerAgentsStorage.entries()); userToMarketplaceListedMainersStorageStable := Iter.toArray(userToMarketplaceListedMainersStorage.entries()); marketplaceReservedMainerAgentsStorageStable := Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); - userToMarketplaceReservedMainerStorageStable := Iter.toArray(userToMarketplaceReservedMainerStorage.entries()); + userToMarketplaceReservedMainerStorageStable := Iter.toArray(marketplaceReservedMainerStorage.entries()); }; system func postupgrade() { @@ -8990,7 +9054,7 @@ actor class GameStateCanister() = this { userToMarketplaceListedMainersStorageStable := []; marketplaceReservedMainerAgentsStorage := HashMap.fromIter(Iter.fromArray(marketplaceReservedMainerAgentsStorageStable), marketplaceReservedMainerAgentsStorageStable.size(), Text.equal, Text.hash); marketplaceReservedMainerAgentsStorageStable := []; - userToMarketplaceReservedMainerStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceReservedMainerStorageStable), userToMarketplaceReservedMainerStorageStable.size(), Principal.equal, Principal.hash); + marketplaceReservedMainerStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceReservedMainerStorageStable), userToMarketplaceReservedMainerStorageStable.size(), Principal.equal, Principal.hash); userToMarketplaceReservedMainerStorageStable := []; }; }; From 8a153d1f487785c5eb1ae9787127ca74155e3a8d Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Tue, 11 Nov 2025 11:11:42 +0000 Subject: [PATCH 09/29] revert variable name --- src/GameState/src/Main.mo | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index dc5c303..4310e3f 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8657,7 +8657,7 @@ actor class GameStateCanister() = this { case (null) {}; }; ignore marketplaceReservedMainerAgentsStorage.remove(mainerAddress); - ignore marketplaceReservedMainerStorage.remove(msg.caller); + ignore userToMarketplaceReservedMainerStorage.remove(msg.caller); return [?#Ok(getNextMainerMarketplaceTransactionId())]; }; @@ -8687,7 +8687,7 @@ actor class GameStateCanister() = this { stable var marketplaceReservedMainerAgentsStorageStable : [(Text, Types.MainerMarketplaceListing)] = []; var marketplaceReservedMainerAgentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); stable var userToMarketplaceReservedMainerStorageStable : [(Principal, Types.MainerMarketplaceListing)] = []; - var marketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); + var userToMarketplaceReservedMainerStorage : HashMap.HashMap = HashMap.HashMap(0, Principal.equal, Principal.hash); // Non-stable: Timer IDs for marketplace reservations (2 minute expiry) var marketplaceReservationTimers : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); @@ -8809,7 +8809,7 @@ actor class GameStateCanister() = this { case (?reservingUserPrincipal) { // Reserve the mAIner and remove it from listings marketplaceReservedMainerAgentsStorage.put(entry.address, entry); - marketplaceReservedMainerStorage.put(reservingUserPrincipal, entry); + userToMarketplaceReservedMainerStorage.put(reservingUserPrincipal, entry); switch (removeMarketplaceListedMainer(entry.address)) { case (true) { // Set a timer to automatically unreserve if purchase isn't completed within timeout period @@ -8828,7 +8828,7 @@ actor class GameStateCanister() = this { case (false) { // Revert reservation changes let removeResult = marketplaceReservedMainerAgentsStorage.remove(entry.address); - let removeResult2 = marketplaceReservedMainerStorage.remove(reservingUserPrincipal); + let removeResult2 = userToMarketplaceReservedMainerStorage.remove(reservingUserPrincipal); return false; }; }; @@ -8863,7 +8863,7 @@ actor class GameStateCanister() = this { // This should not happen }; case (?reservingUserPrincipal) { - let removeResult2 = marketplaceReservedMainerStorage.remove(reservingUserPrincipal); + let removeResult2 = userToMarketplaceReservedMainerStorage.remove(reservingUserPrincipal); }; }; let newListingEntry = { @@ -8881,7 +8881,7 @@ actor class GameStateCanister() = this { }; private func getMarketplaceReservedMainerForUser(userId : Principal) : ?Types.MainerMarketplaceListing { - switch (marketplaceReservedMainerStorage.get(userId)) { + switch (userToMarketplaceReservedMainerStorage.get(userId)) { case (null) { return null; }; case (?userCanisterEntry) { return ?userCanisterEntry; }; }; @@ -9012,7 +9012,7 @@ actor class GameStateCanister() = this { marketplaceListedMainerAgentsStorageStable := Iter.toArray(marketplaceListedMainerAgentsStorage.entries()); userToMarketplaceListedMainersStorageStable := Iter.toArray(userToMarketplaceListedMainersStorage.entries()); marketplaceReservedMainerAgentsStorageStable := Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); - userToMarketplaceReservedMainerStorageStable := Iter.toArray(marketplaceReservedMainerStorage.entries()); + userToMarketplaceReservedMainerStorageStable := Iter.toArray(userToMarketplaceReservedMainerStorage.entries()); }; system func postupgrade() { @@ -9054,7 +9054,7 @@ actor class GameStateCanister() = this { userToMarketplaceListedMainersStorageStable := []; marketplaceReservedMainerAgentsStorage := HashMap.fromIter(Iter.fromArray(marketplaceReservedMainerAgentsStorageStable), marketplaceReservedMainerAgentsStorageStable.size(), Text.equal, Text.hash); marketplaceReservedMainerAgentsStorageStable := []; - marketplaceReservedMainerStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceReservedMainerStorageStable), userToMarketplaceReservedMainerStorageStable.size(), Principal.equal, Principal.hash); + userToMarketplaceReservedMainerStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceReservedMainerStorageStable), userToMarketplaceReservedMainerStorageStable.size(), Principal.equal, Principal.hash); userToMarketplaceReservedMainerStorageStable := []; }; }; From 8973bf84bdddbce3e778aeee1e8102b59380c678 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Tue, 11 Nov 2025 11:24:31 +0000 Subject: [PATCH 10/29] fix: permission issues --- src/GameState/src/Main.mo | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 4310e3f..cd5a728 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -2156,6 +2156,85 @@ actor class GameStateCanister() = this { }; }; + // Admin function to check user-mAIner mapping consistency + // Returns info about any discrepancies between the two storage structures + public shared query (msg) func checkUserMainerMappingConsistencyAdmin() : async Types.AuthRecordResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + // Count total mAIners in main storage + var totalMainersInStorage : Nat = 0; + for ((address, mainerEntry) in mainerAgentCanistersStorage.entries()) { + totalMainersInStorage += 1; + }; + + // Count total mAIners in user mapping + var totalMainersInUserMapping : Nat = 0; + for ((user, mainersList) in userToMainerAgentsStorage.entries()) { + totalMainersInUserMapping += List.size(mainersList); + }; + + // Count unique users in user mapping + var uniqueUsers : Nat = 0; + for ((user, mainersList) in userToMainerAgentsStorage.entries()) { + uniqueUsers += 1; + }; + + let statusMsg = "Total mAIners in storage: " # Nat.toText(totalMainersInStorage) + # ", Total mAIners in user mapping: " # Nat.toText(totalMainersInUserMapping) + # ", Unique users: " # Nat.toText(uniqueUsers); + + if (totalMainersInStorage != totalMainersInUserMapping) { + let authRecord = { auth = "INCONSISTENCY: " # statusMsg # " - Run rebuildUserMainerMappingAdmin() to fix." }; + return #Ok(authRecord); + } else { + let authRecord = { auth = "OK: Mapping is consistent. " # statusMsg }; + return #Ok(authRecord); + }; + }; + + // Admin function to rebuild userToMainerAgentsStorage from mainerAgentCanistersStorage + // This is useful if the user-to-mAIner mapping gets corrupted during an upgrade + public shared (msg) func rebuildUserMainerMappingAdmin() : async Types.AuthRecordResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + // Clear the existing mapping + userToMainerAgentsStorage := HashMap.HashMap(0, Principal.equal, Principal.hash); + + // Rebuild from mainerAgentCanistersStorage + var rebuiltCount : Nat = 0; + for ((address, mainerEntry) in mainerAgentCanistersStorage.entries()) { + // Add this mAIner to the user's list + switch (getUserMainerAgents(mainerEntry.ownedBy)) { + case (null) { + // First mAIner for this user + let userCanistersList : List.List = List.make(mainerEntry); + userToMainerAgentsStorage.put(mainerEntry.ownedBy, userCanistersList); + rebuiltCount += 1; + }; + case (?userCanistersList) { + // Add to existing list, with deduplication based on address + let filteredUserCanistersList : List.List = List.filter(userCanistersList, func(listEntry: Types.OfficialMainerAgentCanister) : Bool { listEntry.address != mainerEntry.address }); + let updatedUserCanistersList : List.List = List.push(mainerEntry, filteredUserCanistersList); + userToMainerAgentsStorage.put(mainerEntry.ownedBy, updatedUserCanistersList); + rebuiltCount += 1; + }; + }; + }; + + let authRecord = { auth = "Rebuilt user-mAIner mapping for " # Nat.toText(rebuiltCount) # " mAIners" }; + return #Ok(authRecord); + }; + // Caution: function that returns all mAIner agents (TODO: decide if needed) private func getMainerAgents() : [Types.OfficialMainerAgentCanister] { var mainerAgents : List.List = List.nil(); From 1c4a0163790c558254b8bd3e72db02079d9e0d20 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Tue, 11 Nov 2025 14:49:48 +0000 Subject: [PATCH 11/29] feature: marketplace --- src/GameState/src/Main.mo | 110 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index cd5a728..cf53038 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -2197,6 +2197,53 @@ actor class GameStateCanister() = this { }; }; + // Admin function to clear all marketplace reservations + // This is useful if reservations get stuck due to timer issues or data corruption + public shared (msg) func clearMarketplaceReservationsAdmin() : async Types.AuthRecordResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + var clearedCount : Nat = 0; + + // Get all reserved mAIners + let reservedEntries = Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); + + // Clear all reservations and return them to listings + for ((address, reservedEntry) in reservedEntries.vals()) { + // Cancel timers if they exist + switch (marketplaceReservationTimers.get(address)) { + case (?timerId) { + Timer.cancelTimer(timerId); + ignore marketplaceReservationTimers.remove(address); + }; + case (null) {}; + }; + + // Return to listings + let listingEntry : Types.MainerMarketplaceListing = { + address = reservedEntry.address; + mainerType = reservedEntry.mainerType; + listedTimestamp = reservedEntry.listedTimestamp; + listedBy = reservedEntry.listedBy; + priceE8S = reservedEntry.priceE8S; + reservedBy = null; + }; + ignore putMarketplaceListedMainer(listingEntry); + clearedCount += 1; + }; + + // Clear the reservation storages + marketplaceReservedMainerAgentsStorage := HashMap.HashMap(0, Text.equal, Text.hash); + userToMarketplaceReservedMainerStorage := HashMap.HashMap(0, Principal.equal, Principal.hash); + + let authRecord = { auth = "Cleared " # Nat.toText(clearedCount) # " marketplace reservations" }; + return #Ok(authRecord); + }; + // Admin function to rebuild userToMainerAgentsStorage from mainerAgentCanistersStorage // This is useful if the user-to-mAIner mapping gets corrupted during an upgrade public shared (msg) func rebuildUserMainerMappingAdmin() : async Types.AuthRecordResult { @@ -8727,6 +8774,16 @@ actor class GameStateCanister() = this { // Add to buyer let addResult : Bool = putUserMainerAgent(newCanisterEntry); + // Record the sale for statistics + let sale : MarketplaceSale = { + mainerAddress = mainerAddress; + seller = mainerEntry.ownedBy; + buyer = msg.caller; + priceE8S = userCanisterEntry.priceE8S; + saleTimestamp = Nat64.fromNat(Int.abs(Time.now())); + }; + marketplaceSalesHistory.add(sale); + // Clean up reservation and cancel the timer switch (marketplaceReservationTimers.get(mainerAddress)) { case (?timerId) { @@ -8772,6 +8829,17 @@ actor class GameStateCanister() = this { var marketplaceReservationTimers : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); let MARKETPLACE_RESERVATION_TIMEOUT_SECONDS : Nat = 120; // 2 minutes + // Marketplace sales history for statistics + public type MarketplaceSale = { + mainerAddress : Text; + seller : Principal; + buyer : Principal; + priceE8S : Nat; + saleTimestamp : Nat64; + }; + stable var marketplaceSalesHistoryStable : [MarketplaceSale] = []; + var marketplaceSalesHistory : Buffer.Buffer = Buffer.Buffer(0); + // CRUD helper functions for listings private func putMarketplaceListedMainer(entry : Types.MainerMarketplaceListing) : Types.MainerMarketplaceListing { marketplaceListedMainerAgentsStorage.put(entry.address, entry); @@ -8984,6 +9052,33 @@ actor class GameStateCanister() = this { return #Ok(getAllMarketplaceListedMainers()); }; + public type MarketplaceStats = { + totalSales : Nat; + totalVolumeE8S : Nat; + uniqueBuyers : Nat; + uniqueSellers : Nat; + }; + + public query func getMarketplaceSalesStats() : async MarketplaceStats { + // Calculate stats from sales history + var totalVolumeE8S : Nat = 0; + var buyersSet = HashMap.HashMap(0, Principal.equal, Principal.hash); + var sellersSet = HashMap.HashMap(0, Principal.equal, Principal.hash); + + for (sale in marketplaceSalesHistory.vals()) { + totalVolumeE8S += sale.priceE8S; + buyersSet.put(sale.buyer, true); + sellersSet.put(sale.seller, true); + }; + + return { + totalSales = marketplaceSalesHistory.size(); + totalVolumeE8S = totalVolumeE8S; + uniqueBuyers = buyersSet.size(); + uniqueSellers = sellersSet.size(); + }; + }; + public shared (msg) func reserveMarketplaceListedMainer(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.MainerMarketplaceReservationResult { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); @@ -9041,7 +9136,7 @@ actor class GameStateCanister() = this { }; public shared (msg) func cancelMarketplaceReservation(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.StatusCodeRecordResult { - // Allow the original seller to cancel a stuck reservation and relist their mAIner + // Allow the original seller OR the buyer who reserved it to cancel the reservation if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); }; @@ -9052,8 +9147,14 @@ actor class GameStateCanister() = this { return #Err(#Unauthorized); // Not reserved }; case (?reservedEntry) { - // Only the original seller can cancel the reservation - if (reservedEntry.listedBy != msg.caller) { + // Allow either the seller OR the buyer who reserved it to cancel + let isSeller = reservedEntry.listedBy == msg.caller; + let isBuyer = switch (reservedEntry.reservedBy) { + case (null) { false }; + case (?buyer) { buyer == msg.caller }; + }; + + if (not isSeller and not isBuyer) { return #Err(#Unauthorized); }; @@ -9092,6 +9193,7 @@ actor class GameStateCanister() = this { userToMarketplaceListedMainersStorageStable := Iter.toArray(userToMarketplaceListedMainersStorage.entries()); marketplaceReservedMainerAgentsStorageStable := Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); userToMarketplaceReservedMainerStorageStable := Iter.toArray(userToMarketplaceReservedMainerStorage.entries()); + marketplaceSalesHistoryStable := Buffer.toArray(marketplaceSalesHistory); }; system func postupgrade() { @@ -9135,5 +9237,7 @@ actor class GameStateCanister() = this { marketplaceReservedMainerAgentsStorageStable := []; userToMarketplaceReservedMainerStorage := HashMap.fromIter(Iter.fromArray(userToMarketplaceReservedMainerStorageStable), userToMarketplaceReservedMainerStorageStable.size(), Principal.equal, Principal.hash); userToMarketplaceReservedMainerStorageStable := []; + marketplaceSalesHistory := Buffer.fromArray(marketplaceSalesHistoryStable); + marketplaceSalesHistoryStable := []; }; }; From 6406e619510f41f8ef48ffc1d1870112a033518f Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Tue, 11 Nov 2025 15:58:40 +0000 Subject: [PATCH 12/29] fix: page refresh --- src/GameState/src/Main.mo | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index cf53038..e4593e0 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -9047,6 +9047,14 @@ actor class GameStateCanister() = this { }; }; + // Get user's current reservation (if any) + public query (msg) func getUserMarketplaceReservation() : async ?Types.MainerMarketplaceListing { + if (Principal.isAnonymous(msg.caller)) { + return null; + }; + return getMarketplaceReservedMainerForUser(msg.caller); + }; + public query (msg) func getMarketplaceMainerListings() : async Types.MainerMarketplaceListingsResult { // Retrieve all mAIner marketplace listings return #Ok(getAllMarketplaceListedMainers()); From 971198a76eb1a39f5a3e9e7c5eade44088bd2733 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Tue, 11 Nov 2025 16:34:13 +0000 Subject: [PATCH 13/29] fix: refresh page --- src/GameState/src/Main.mo | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index e4593e0..927fc14 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -9055,6 +9055,14 @@ actor class GameStateCanister() = this { return getMarketplaceReservedMainerForUser(msg.caller); }; + // Admin: Check if a specific mAIner is in reserved storage (for debugging) + public query func isMainerInReservedStorage(mainerAddress : Text) : async Bool { + switch (marketplaceReservedMainerAgentsStorage.get(mainerAddress)) { + case (null) { return false; }; + case (?_) { return true; }; + }; + }; + public query (msg) func getMarketplaceMainerListings() : async Types.MainerMarketplaceListingsResult { // Retrieve all mAIner marketplace listings return #Ok(getAllMarketplaceListedMainers()); From 427c95afcb0f74acb634a47b413e351234e270d1 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 12 Nov 2025 15:21:22 +0000 Subject: [PATCH 14/29] put marketplace functions all together --- src/GameState/src/Main.mo | 94 +++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 927fc14..30eef66 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -2197,53 +2197,6 @@ actor class GameStateCanister() = this { }; }; - // Admin function to clear all marketplace reservations - // This is useful if reservations get stuck due to timer issues or data corruption - public shared (msg) func clearMarketplaceReservationsAdmin() : async Types.AuthRecordResult { - if (Principal.isAnonymous(msg.caller)) { - return #Err(#Unauthorized); - }; - if (not Principal.isController(msg.caller)) { - return #Err(#Unauthorized); - }; - - var clearedCount : Nat = 0; - - // Get all reserved mAIners - let reservedEntries = Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); - - // Clear all reservations and return them to listings - for ((address, reservedEntry) in reservedEntries.vals()) { - // Cancel timers if they exist - switch (marketplaceReservationTimers.get(address)) { - case (?timerId) { - Timer.cancelTimer(timerId); - ignore marketplaceReservationTimers.remove(address); - }; - case (null) {}; - }; - - // Return to listings - let listingEntry : Types.MainerMarketplaceListing = { - address = reservedEntry.address; - mainerType = reservedEntry.mainerType; - listedTimestamp = reservedEntry.listedTimestamp; - listedBy = reservedEntry.listedBy; - priceE8S = reservedEntry.priceE8S; - reservedBy = null; - }; - ignore putMarketplaceListedMainer(listingEntry); - clearedCount += 1; - }; - - // Clear the reservation storages - marketplaceReservedMainerAgentsStorage := HashMap.HashMap(0, Text.equal, Text.hash); - userToMarketplaceReservedMainerStorage := HashMap.HashMap(0, Principal.equal, Principal.hash); - - let authRecord = { auth = "Cleared " # Nat.toText(clearedCount) # " marketplace reservations" }; - return #Ok(authRecord); - }; - // Admin function to rebuild userToMainerAgentsStorage from mainerAgentCanistersStorage // This is useful if the user-to-mAIner mapping gets corrupted during an upgrade public shared (msg) func rebuildUserMainerMappingAdmin() : async Types.AuthRecordResult { @@ -9063,6 +9016,53 @@ actor class GameStateCanister() = this { }; }; + // Admin function to clear all marketplace reservations + // This is useful if reservations get stuck due to timer issues or data corruption + public shared (msg) func clearMarketplaceReservationsAdmin() : async Types.AuthRecordResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + var clearedCount : Nat = 0; + + // Get all reserved mAIners + let reservedEntries = Iter.toArray(marketplaceReservedMainerAgentsStorage.entries()); + + // Clear all reservations and return them to listings + for ((address, reservedEntry) in reservedEntries.vals()) { + // Cancel timers if they exist + switch (marketplaceReservationTimers.get(address)) { + case (?timerId) { + Timer.cancelTimer(timerId); + ignore marketplaceReservationTimers.remove(address); + }; + case (null) {}; + }; + + // Return to listings + let listingEntry : Types.MainerMarketplaceListing = { + address = reservedEntry.address; + mainerType = reservedEntry.mainerType; + listedTimestamp = reservedEntry.listedTimestamp; + listedBy = reservedEntry.listedBy; + priceE8S = reservedEntry.priceE8S; + reservedBy = null; + }; + ignore putMarketplaceListedMainer(listingEntry); + clearedCount += 1; + }; + + // Clear the reservation storages + marketplaceReservedMainerAgentsStorage := HashMap.HashMap(0, Text.equal, Text.hash); + userToMarketplaceReservedMainerStorage := HashMap.HashMap(0, Principal.equal, Principal.hash); + + let authRecord = { auth = "Cleared " # Nat.toText(clearedCount) # " marketplace reservations" }; + return #Ok(authRecord); + }; + public query (msg) func getMarketplaceMainerListings() : async Types.MainerMarketplaceListingsResult { // Retrieve all mAIner marketplace listings return #Ok(getAllMarketplaceListedMainers()); From 4dfd92d473d36c98ca90282b9c9a84a321eb6165 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 12 Nov 2025 15:23:04 +0000 Subject: [PATCH 15/29] comment out risky function --- src/GameState/src/Main.mo | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 30eef66..33a2ebe 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -2197,8 +2197,11 @@ actor class GameStateCanister() = this { }; }; - // Admin function to rebuild userToMainerAgentsStorage from mainerAgentCanistersStorage + // COMMENTED OUT: Admin function to rebuild userToMainerAgentsStorage from mainerAgentCanistersStorage // This is useful if the user-to-mAIner mapping gets corrupted during an upgrade + // WARNING: This function is too risky to leave enabled as it clears and rebuilds the entire mapping + // Uncomment only if absolutely necessary for emergency recovery + /* public shared (msg) func rebuildUserMainerMappingAdmin() : async Types.AuthRecordResult { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); @@ -2234,6 +2237,7 @@ actor class GameStateCanister() = this { let authRecord = { auth = "Rebuilt user-mAIner mapping for " # Nat.toText(rebuiltCount) # " mAIners" }; return #Ok(authRecord); }; + */ // Caution: function that returns all mAIner agents (TODO: decide if needed) private func getMainerAgents() : [Types.OfficialMainerAgentCanister] { From c0468b28bc966beb76ed7b902855ce56b1abe0c6 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 12 Nov 2025 15:26:31 +0000 Subject: [PATCH 16/29] move into types.mo --- src/GameState/src/Main.mo | 22 ++++------------------ src/common/Types.mo | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 33a2ebe..4c20759 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8732,7 +8732,7 @@ actor class GameStateCanister() = this { let addResult : Bool = putUserMainerAgent(newCanisterEntry); // Record the sale for statistics - let sale : MarketplaceSale = { + let sale : Types.MarketplaceSale = { mainerAddress = mainerAddress; seller = mainerEntry.ownedBy; buyer = msg.caller; @@ -8787,15 +8787,8 @@ actor class GameStateCanister() = this { let MARKETPLACE_RESERVATION_TIMEOUT_SECONDS : Nat = 120; // 2 minutes // Marketplace sales history for statistics - public type MarketplaceSale = { - mainerAddress : Text; - seller : Principal; - buyer : Principal; - priceE8S : Nat; - saleTimestamp : Nat64; - }; - stable var marketplaceSalesHistoryStable : [MarketplaceSale] = []; - var marketplaceSalesHistory : Buffer.Buffer = Buffer.Buffer(0); + stable var marketplaceSalesHistoryStable : [Types.MarketplaceSale] = []; + var marketplaceSalesHistory : Buffer.Buffer = Buffer.Buffer(0); // CRUD helper functions for listings private func putMarketplaceListedMainer(entry : Types.MainerMarketplaceListing) : Types.MainerMarketplaceListing { @@ -9072,14 +9065,7 @@ actor class GameStateCanister() = this { return #Ok(getAllMarketplaceListedMainers()); }; - public type MarketplaceStats = { - totalSales : Nat; - totalVolumeE8S : Nat; - uniqueBuyers : Nat; - uniqueSellers : Nat; - }; - - public query func getMarketplaceSalesStats() : async MarketplaceStats { + public query func getMarketplaceSalesStats() : async Types.MarketplaceStats { // Calculate stats from sales history var totalVolumeE8S : Nat = 0; var buyersSet = HashMap.HashMap(0, Principal.equal, Principal.hash); diff --git a/src/common/Types.mo b/src/common/Types.mo index 3219520..5646a3d 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -360,6 +360,21 @@ module Types { public type MainerMarketplaceReservationResult = Result; + public type MarketplaceSale = { + mainerAddress : Text; + seller : Principal; + buyer : Principal; + priceE8S : Nat; + saleTimestamp : Nat64; + }; + + public type MarketplaceStats = { + totalSales : Nat; + totalVolumeE8S : Nat; + uniqueBuyers : Nat; + uniqueSellers : Nat; + }; + public type CanisterInput = { address : CanisterAddress; subnet : Text; From 72abfe1a7a88c9132eca6e855a5f459925dde384 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 12 Nov 2025 15:35:20 +0000 Subject: [PATCH 17/29] rename function --- src/GameState/src/Main.mo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 4c20759..dc2fc64 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -9006,7 +9006,7 @@ actor class GameStateCanister() = this { }; // Admin: Check if a specific mAIner is in reserved storage (for debugging) - public query func isMainerInReservedStorage(mainerAddress : Text) : async Bool { + public query func isMainerReservedOnMarketplaceAdmin(mainerAddress : Text) : async Bool { switch (marketplaceReservedMainerAgentsStorage.get(mainerAddress)) { case (null) { return false; }; case (?_) { return true; }; From ef5f4f5f7096e4c56b6cae24d8a211088da84745 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 12 Nov 2025 15:37:37 +0000 Subject: [PATCH 18/29] add auth checks --- src/GameState/src/Main.mo | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index dc2fc64..0209db8 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -9006,7 +9006,14 @@ actor class GameStateCanister() = this { }; // Admin: Check if a specific mAIner is in reserved storage (for debugging) - public query func isMainerReservedOnMarketplaceAdmin(mainerAddress : Text) : async Bool { + public query (msg) func isMainerReservedOnMarketplaceAdmin(mainerAddress : Text) : async Bool { + if (Principal.isAnonymous(msg.caller)) { + return false; + }; + if (not Principal.isController(msg.caller)) { + return false; + }; + switch (marketplaceReservedMainerAgentsStorage.get(mainerAddress)) { case (null) { return false; }; case (?_) { return true; }; From 147a973752293205151bb819fb51762a22b0bb23 Mon Sep 17 00:00:00 2001 From: icpp Date: Fri, 14 Nov 2025 23:18:46 -0500 Subject: [PATCH 19/29] Implements AdminRBAC for GameState; Applies to getMainerAgentCanistersAdmin --- src/GameState/src/Main.mo | 114 +++++++++++++++++++++++++++++++++++++- src/common/Types.mo | 19 +++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 63329a0..c3acf22 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -1964,7 +1964,113 @@ actor class GameStateCanister() = this { return dailyIdleBurnRate; }; - + //------------------------------------------------------------------------- + // Admin RBAC Storage + //------------------------------------------------------------------------- + stable var adminRoleAssignmentsStable : [(Text, Types.AdminRoleAssignment)] = []; + var adminRoleAssignmentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + + private func putAdminRole(principal : Text, assignment : Types.AdminRoleAssignment) : Bool { + adminRoleAssignmentsStorage.put(principal, assignment); + return true; + }; + + private func getAdminRole(principal : Text) : ?Types.AdminRoleAssignment { + switch (adminRoleAssignmentsStorage.get(principal)) { + case (null) { return null; }; + case (?assignment) { return ?assignment; }; + }; + }; + + private func removeAdminRole(principal : Text) : Bool { + switch (adminRoleAssignmentsStorage.get(principal)) { + case (null) { return false; }; + case (?assignment) { + let removeResult = adminRoleAssignmentsStorage.remove(principal); + return true; + }; + }; + }; + + private func getAllAdminRoles() : [Types.AdminRoleAssignment] { + let assignments : Iter.Iter = adminRoleAssignmentsStorage.vals(); + return Iter.toArray(assignments); + }; + + // Helper function to check admin permissions + private func hasAdminRole(principal : Principal, requiredRole : Types.AdminRole) : Bool { + // Controllers automatically have all permissions + if (Principal.isController(principal)) { + return true; + }; + + // Check for assigned role + let principalText = Principal.toText(principal); + switch (getAdminRole(principalText)) { + case (null) { return false; }; + case (?assignment) { + switch (assignment.role, requiredRole) { + // AdminUpdate includes AdminQuery + case (#AdminUpdate, #AdminQuery) { true }; + case (#AdminUpdate, #AdminUpdate) { true }; + // AdminQuery only has query permissions + case (#AdminQuery, #AdminQuery) { true }; + // All other combinations fail + case _ { false }; + }; + }; + }; + }; + + // Add an admin role assignment (controller-only) + public shared(msg) func assignAdminRole( + principal: Text, + role: Types.AdminRole, + note: Text + ) : async Types.AdminRoleAssignmentResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + let assignment : Types.AdminRoleAssignment = { + principal = principal; + role = role; + assignedBy = Principal.toText(msg.caller); + assignedAt = Nat64.fromNat(Int.abs(Time.now())); + note = note; + }; + + // Store the assignment (replaces any existing assignment for this principal) + let _ = putAdminRole(principal, assignment); + + #Ok(assignment) + }; + + // Remove an admin role assignment (controller-only) + public shared(msg) func revokeAdminRole(principal: Text) : async Types.TextResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + let removed = removeAdminRole(principal); + if (removed) { + #Ok("Admin role revoked for principal: " # principal) + } else { + #Err(#Other("No admin role found for principal: " # principal)) + } + }; + + // Get all admin role assignments (controller-only) + public shared query(msg) func getAdminRoles() : async Types.AdminRoleAssignmentsResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + #Ok(getAllAdminRoles()) + }; + + //------------------------------------------------------------------------- + // Official Challenger canisters stable var challengerCanistersStorageStable : [(Text, Types.OfficialProtocolCanister)] = []; var challengerCanistersStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); @@ -7166,7 +7272,8 @@ actor class GameStateCanister() = this { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); }; - if (not Principal.isController(msg.caller)) { + // Check if caller has AdminQuery permission + if (not hasAdminRole(msg.caller, #AdminQuery)) { return #Err(#Unauthorized); }; @@ -8188,6 +8295,7 @@ actor class GameStateCanister() = this { sharedServiceCanistersStorageStable := Iter.toArray(sharedServiceCanistersStorage.entries()); redeemedTransactionBlocksStorageStable := Iter.toArray(redeemedTransactionBlocksStorage.entries()); redeemedFunnaiTransactionBlocksStorageStable := Iter.toArray(redeemedFunnaiTransactionBlocksStorage.entries()); + adminRoleAssignmentsStable := Iter.toArray(adminRoleAssignmentsStorage.entries()); }; system func postupgrade() { @@ -8223,5 +8331,7 @@ actor class GameStateCanister() = this { redeemedTransactionBlocksStorageStable := []; redeemedFunnaiTransactionBlocksStorage := HashMap.fromIter(Iter.fromArray(redeemedFunnaiTransactionBlocksStorageStable), redeemedFunnaiTransactionBlocksStorageStable.size(), Nat.equal, Hash.hash); redeemedFunnaiTransactionBlocksStorageStable := []; + adminRoleAssignmentsStorage := HashMap.fromIter(Iter.fromArray(adminRoleAssignmentsStable), adminRoleAssignmentsStable.size(), Text.equal, Text.hash); + adminRoleAssignmentsStable := []; }; }; diff --git a/src/common/Types.mo b/src/common/Types.mo index ec6f63d..8cd6cf1 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -26,6 +26,25 @@ module Types { #Err : E; }; + //------------------------------------------------------------------------- + // Admin RBAC Types + public type AdminRole = { + #AdminUpdate; // Access to Admin endpoints requiring #AdminUpdate or #AdminQuery roles only; No access to endpoints requiring controller level + #AdminQuery; // Access to Admin endpoints requiring #AdminQuery role only. + }; + + public type AdminRoleAssignment = { + principal : Text; // Principal in text format + role : AdminRole; + assignedBy : Text; // Principal in text format + assignedAt : Nat64; + note : Text; + }; + + // Result types for admin endpoints + public type AdminRoleAssignmentResult = Result; + public type AdminRoleAssignmentsResult = Result<[AdminRoleAssignment], ApiError>; + //------------------------------------------------------------------------- public type AuthRecord = { auth : Text; From 6b9954ad455edcc52b3972f198ce493cfe5c3c31 Mon Sep 17 00:00:00 2001 From: patnorris Date: Wed, 19 Nov 2025 14:13:58 +0100 Subject: [PATCH 20/29] Incorporate PR feedback --- src/GameState/src/Main.mo | 62 +++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 0209db8..06388f9 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8262,7 +8262,7 @@ actor class GameStateCanister() = this { let icrc7Symbol : Text = "MAINERS"; let icrc7Name : Text = "funnAI mAIners"; let icrc7Description : Text = "mAIner AI agents listed on the funnAI marketplace."; - let icrc7Logo : Text = "https://funnai.onicai.com/funnai.webp"; + let icrc7Logo : Text = "https://funnai.onicai.com/funnai_192.webp"; public query func icrc7_symbol() : async Text { return icrc7Symbol; @@ -8471,14 +8471,17 @@ actor class GameStateCanister() = this { return [?#Err(#Unauthorized)]; }; let approveTokenArg : ICRC37.Service.ApproveTokenArg = args[0]; + D.print("GameState: icrc37_approve_tokens - approveTokenArg: "# debug_show(approveTokenArg)); if (approveTokenArg.token_id < 1000000) { // Price has to be at least 0.01 ICP + D.print("GameState: icrc37_approve_tokens - specified price to small: "# debug_show(approveTokenArg.token_id)); return [?#Err(#Unauthorized)]; }; // Get mAIner address from memo switch (approveTokenArg.approval_info.memo) { case (null) { // No mAIner canister specified + D.print("GameState: icrc37_approve_tokens - no mAIner canister specified (as memo): "# debug_show(approveTokenArg.approval_info.memo)); return [?#Err(#Unauthorized)]; }; case (?approvalMemo) { @@ -8486,20 +8489,25 @@ actor class GameStateCanister() = this { switch (text) { case (null) { // No mAIner canister specified + D.print("GameState: icrc37_approve_tokens - no mAIner canister received from decoded memo: "# debug_show(approvalMemo)); return [?#Err(#Unauthorized)]; }; case (?mainerAddress) { + D.print("GameState: icrc37_approve_tokens - specified mainerAddress (in memo): "# debug_show(mainerAddress)); // Confirm caller owns mAIner switch (getUserMainerAgents(msg.caller)) { case (null) { + D.print("GameState: icrc37_approve_tokens - caller does not own any mAIners: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?userMainerEntries) { switch (List.find(userMainerEntries, func(mainerEntry: Types.OfficialMainerAgentCanister) : Bool { mainerEntry.address == mainerAddress } )) { case (null) { + D.print("GameState: icrc37_approve_tokens - caller does not own the mAIner: "# debug_show(msg.caller)); return [?#Err(#NonExistingTokenId)]; }; case (?userMainerEntry) { + D.print("GameState: icrc37_approve_tokens - specified mAIner exists: "# debug_show(userMainerEntry)); // Sanity checks on userMainerEntry (i.e. address provided is correct and matches entry info) switch (userMainerEntry.canisterType) { case (#MainerAgent(mainerAgentCanisterType)) { @@ -8517,6 +8525,7 @@ actor class GameStateCanister() = this { reservedBy : ?Principal = null; }; let result = putMarketplaceListedMainer(entry); + D.print("GameState: icrc37_approve_tokens - added mAIner to listings: "# debug_show(entry)); return [?#Ok(getNextMainerMarketplaceTransactionId())]; }; }; @@ -8563,10 +8572,12 @@ actor class GameStateCanister() = this { return [?#Err(#Unauthorized)]; }; let revokeTokenArg : ICRC37.Service.RevokeTokenApprovalArg = args[0]; + D.print("GameState: icrc37_revoke_token_approvals - revokeTokenArg: "# debug_show(revokeTokenArg)); // Get mAIner address from memo switch (revokeTokenArg.memo) { case (null) { // No mAIner canister specified + D.print("GameState: icrc37_revoke_token_approvals - No mAIner canister specified by caller: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?revokeMemo) { @@ -8574,20 +8585,24 @@ actor class GameStateCanister() = this { switch (text) { case (null) { // No mAIner canister specified + D.print("GameState: icrc37_revoke_token_approvals - No mAIner canister specified by caller: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?mainerAddress) { // Confirm caller owns mAIner switch (getUserMainerAgents(msg.caller)) { case (null) { + D.print("GameState: icrc37_revoke_token_approvals - caller doesn't own any mAIner: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?userMainerEntries) { switch (List.find(userMainerEntries, func(mainerEntry: Types.OfficialMainerAgentCanister) : Bool { mainerEntry.address == mainerAddress } )) { case (null) { + D.print("GameState: icrc37_revoke_token_approvals - caller doesn't own mAIner: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?userMainerEntry) { + D.print("GameState: icrc37_revoke_token_approvals - mAIner exists: "# debug_show(userMainerEntry)); switch (userMainerEntry.canisterType) { case (#MainerAgent(mainerAgentCanisterType)) { // Check that mAIner is listed currently @@ -8597,7 +8612,8 @@ actor class GameStateCanister() = this { // Remove mAIner from listings switch (removeMarketplaceListedMainer(mainerAddress)) { case (false) { return [?#Err(#Unauthorized)]; }; - case (true) { + case (true) { + D.print("GameState: icrc37_revoke_token_approvals - removed mAIner from listings: "# debug_show(canisterEntry)); return [?#Ok(getNextMainerMarketplaceTransactionId())]; }; }; @@ -8648,9 +8664,11 @@ actor class GameStateCanister() = this { return [?#Err(#Unauthorized)]; }; let transferTokenArg : ICRC37.Service.TransferFromArg = args[0]; + D.print("GameState: icrc37_transfer_from - transferTokenArg: "# debug_show(transferTokenArg)); switch (transferTokenArg.memo) { case (null) { // No mAIner canister specified + D.print("GameState: icrc37_transfer_from - no mAIner specified by caller: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?transferMemo) { @@ -8658,29 +8676,38 @@ actor class GameStateCanister() = this { switch (text) { case (null) { // No mAIner canister specified + D.print("GameState: icrc37_transfer_from - no mAIner address received from caller: "# debug_show(msg.caller)); return [?#Err(#Unauthorized)]; }; case (?mainerAddress) { + D.print("GameState: icrc37_transfer_from - mainerAddress: "# debug_show(mainerAddress)); let transactionToVerify = Nat64.fromNat(transferTokenArg.token_id); switch (checkExistingTransactionBlock(transactionToVerify)) { case (false) { // new transaction, continue + D.print("GameState: icrc37_transfer_from - new transaction: "# debug_show(transactionToVerify)); }; case (true) { // already redeem transaction + D.print("GameState: icrc37_transfer_from - double spending: "# debug_show(transactionToVerify)); return [?#Err(#Unauthorized)]; // no double spending }; }; // Verify that caller has reserved the mAIner switch (getMarketplaceReservedMainerForUser(msg.caller)) { - case (null) { return [?#Err(#Unauthorized)]; }; + case (null) { + D.print("GameState: icrc37_transfer_from - caller doesn't have a reservation: "# debug_show(msg.caller)); + return [?#Err(#Unauthorized)]; + }; case (?userCanisterEntry) { if (userCanisterEntry.address != mainerAddress) { + D.print("GameState: icrc37_transfer_from - caller has a different reservation: "# debug_show(msg.caller) # debug_show(userCanisterEntry)); return [?#Err(#Unauthorized)]; }; switch (getMainerAgentCanister(mainerAddress)) { case (null) { return [?#Err(#InvalidRecipient)]; }; case (?mainerEntry) { + D.print("GameState: icrc37_transfer_from - mainerEntry: "# debug_show(mainerEntry)); // TODO: Verify user's payment for this agent via the TransactionBlockId (incl. correct price) /* var verifiedPayment : Bool = false; var amountPaid : Nat = 0; @@ -8723,13 +8750,16 @@ actor class GameStateCanister() = this { status : Types.CanisterStatus = mainerEntry.status; mainerConfig : Types.MainerConfigurationInput = mainerEntry.mainerConfig; }; - let updateResult : Types.MainerAgentCanisterResult = putMainerAgentCanister(mainerAddress, newCanisterEntry); + let updateResult : Types.MainerAgentCanisterResult = putMainerAgentCanister(mainerAddress, newCanisterEntry); + D.print("GameState: icrc37_transfer_from - updated mainerEntry: "# debug_show(newCanisterEntry)); // Remove from seller let removeResult : Bool = removeUserMainerAgent(mainerEntry); + D.print("GameState: icrc37_transfer_from - removed from seller: "# debug_show(removeResult)); // Add to buyer let addResult : Bool = putUserMainerAgent(newCanisterEntry); + D.print("GameState: icrc37_transfer_from - added to buyer: "# debug_show(addResult)); // Record the sale for statistics let sale : Types.MarketplaceSale = { @@ -8740,6 +8770,7 @@ actor class GameStateCanister() = this { saleTimestamp = Nat64.fromNat(Int.abs(Time.now())); }; marketplaceSalesHistory.add(sale); + D.print("GameState: icrc37_transfer_from - sale record: "# debug_show(sale)); // Clean up reservation and cancel the timer switch (marketplaceReservationTimers.get(mainerAddress)) { @@ -8749,8 +8780,9 @@ actor class GameStateCanister() = this { }; case (null) {}; }; - ignore marketplaceReservedMainerAgentsStorage.remove(mainerAddress); - ignore userToMarketplaceReservedMainerStorage.remove(msg.caller); + ignore marketplaceReservedMainerAgentsStorage.remove(mainerAddress); + ignore userToMarketplaceReservedMainerStorage.remove(msg.caller); + D.print("GameState: icrc37_transfer_from - cleared reservation for mainerAddress: "# debug_show(mainerAddress)); return [?#Ok(getNextMainerMarketplaceTransactionId())]; }; @@ -9096,12 +9128,17 @@ actor class GameStateCanister() = this { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); }; + D.print("GameState: reserveMarketplaceListedMainer - called by: "# debug_show(msg.caller)); + D.print("GameState: reserveMarketplaceListedMainer - reservationInput: "# debug_show(reservationInput)); // Verify user doesn't have a reservation yet switch (getMarketplaceReservedMainerForUser(msg.caller)) { case (null) { // Continue }; - case (?userCanisterEntry) { return #Err(#Unauthorized); }; + case (?userCanisterEntry) { + D.print("GameState: reserveMarketplaceListedMainer - caller already has a reservation: "# debug_show(userCanisterEntry)); + return #Err(#Unauthorized); + }; }; // Verify mAIner is not reserved @@ -9109,12 +9146,18 @@ actor class GameStateCanister() = this { case (null) { // Continue }; - case (?canisterEntry) { return #Err(#Unauthorized); }; + case (?canisterEntry) { + D.print("GameState: reserveMarketplaceListedMainer - mAIner is already reserved: "# debug_show(canisterEntry)); + return #Err(#Unauthorized); + }; }; // Verify mAIner is listed switch (getMarketplaceListedMainer(reservationInput.address)) { - case (null) { return #Err(#Unauthorized); }; + case (null) { + D.print("GameState: reserveMarketplaceListedMainer - mAIner is not listed: "# debug_show(reservationInput.address)); + return #Err(#Unauthorized); + }; case (?canisterEntry) { // Reserve mAIner for buying (incl. removing listing during purchase completion) let newEntry : Types.MainerMarketplaceListing = { @@ -9126,6 +9169,7 @@ actor class GameStateCanister() = this { reservedBy : ?Principal = ?msg.caller; // Only change }; let result = putMarketplaceReservedMainer(newEntry); + D.print("GameState: reserveMarketplaceListedMainer - reserved mAIner: "# debug_show(newEntry)); return #Ok(newEntry); }; }; From f1c468c1a3d0dd1a912f99540bd4eb6d775ab0c1 Mon Sep 17 00:00:00 2001 From: icpp Date: Wed, 19 Nov 2025 08:21:18 -0500 Subject: [PATCH 21/29] Admin RBAC for mAIners --- src/GameState/src/Main.mo | 2 +- src/mAIner/canister_ids.json | 13 ++-- src/mAIner/src/Main.mo | 130 +++++++++++++++++++++++++++++++++-- 3 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index c3acf22..d033b3a 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -1968,7 +1968,7 @@ actor class GameStateCanister() = this { // Admin RBAC Storage //------------------------------------------------------------------------- stable var adminRoleAssignmentsStable : [(Text, Types.AdminRoleAssignment)] = []; - var adminRoleAssignmentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + transient var adminRoleAssignmentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); private func putAdminRole(principal : Text, assignment : Types.AdminRoleAssignment) : Bool { adminRoleAssignmentsStorage.put(principal, assignment); diff --git a/src/mAIner/canister_ids.json b/src/mAIner/canister_ids.json index 458e658..4b48cc3 100644 --- a/src/mAIner/canister_ids.json +++ b/src/mAIner/canister_ids.json @@ -8,7 +8,7 @@ "prd": "rilmv-caaaa-aaaaa-qandq-cai" }, "mainer_ctrlb_canister_0": { - "testing": "v2hks-haaaa-aaaam-qdxha-cai", + "testing": "6iqjc-haaaa-aaaam-qeoua-cai", "prd": "w57bc-naaaa-aaaaa-qbdea-cai", "demo": "mlfba-yqaaa-aaaaj-a2c2q-cai", "development": "mqa5f-ciaaa-aaaaj-a2cya-cai" @@ -16,24 +16,27 @@ "mainer_ctrlb_canister_1": { "prd": "hnnie-5qaaa-aaaaa-qbl5q-cai", "demo": "kvlg2-diaaa-aaaaj-a2coa-cai", - "testing": "5suig-4yaaa-aaaam-qd5ba-cai", + "testing": "2calm-zyaaa-aaaam-qeooq-cai", "development": "s7xkv-iqaaa-aaaaj-a2hja-cai" }, "mainer_ctrlb_canister_2": { "ic": "33rok-raaaa-aaaaa-qbj6q-cai", "prd": "6als3-xqaaa-aaaaa-qbjbq-cai", "demo": "kamxx-caaaa-aaaaj-a2cnq-cai", - "development": "sywmb-fiaaa-aaaaj-a2hjq-cai" + "development": "sywmb-fiaaa-aaaaj-a2hjq-cai", + "testing": "26er5-oyaaa-aaaam-qeomq-cai" }, "mainer_ctrlb_canister_3": { "prd": "ihph7-gqaaa-aaaaa-qbgwq-cai", "demo": "kskao-oqaaa-aaaaj-a2coq-cai", - "development": "swubj-6yaaa-aaaaj-a2hiq-cai" + "development": "swubj-6yaaa-aaaaj-a2hiq-cai", + "testing": "5suig-4yaaa-aaaam-qd5ba-cai" }, "mainer_ctrlb_canister_4": { "prd": "3pxx5-haaaa-aaaaa-qb5jq-cai", "demo": "ff3j4-tyaaa-aaaaj-a2ewq-cai", - "development": "73zah-5aaaa-aaaaj-a2b7q-cai" + "development": "73zah-5aaaa-aaaaj-a2b7q-cai", + "testing": "v2hks-haaaa-aaaam-qdxha-cai" }, "mainer_ctrlb_canister_5": { "prd": "hrjsv-kqaaa-aaaaa-qbl7q-cai", diff --git a/src/mAIner/src/Main.mo b/src/mAIner/src/Main.mo index 98b2b65..fc99bdf 100644 --- a/src/mAIner/src/Main.mo +++ b/src/mAIner/src/Main.mo @@ -270,10 +270,117 @@ actor class MainerAgentCtrlbCanister() = this { }; }; + //------------------------------------------------------------------------- + // Admin RBAC Storage + //------------------------------------------------------------------------- + stable var adminRoleAssignmentsStable : [(Text, Types.AdminRoleAssignment)] = []; + transient var adminRoleAssignmentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + + private func putAdminRole(principal : Text, assignment : Types.AdminRoleAssignment) : Bool { + adminRoleAssignmentsStorage.put(principal, assignment); + return true; + }; + + private func getAdminRole(principal : Text) : ?Types.AdminRoleAssignment { + switch (adminRoleAssignmentsStorage.get(principal)) { + case (null) { return null; }; + case (?assignment) { return ?assignment; }; + }; + }; + + private func removeAdminRole(principal : Text) : Bool { + switch (adminRoleAssignmentsStorage.get(principal)) { + case (null) { return false; }; + case (?assignment) { + let removeResult = adminRoleAssignmentsStorage.remove(principal); + return true; + }; + }; + }; + + private func getAllAdminRoles() : [Types.AdminRoleAssignment] { + let assignments : Iter.Iter = adminRoleAssignmentsStorage.vals(); + return Iter.toArray(assignments); + }; + + // Helper function to check admin permissions + private func hasAdminRole(principal : Principal, requiredRole : Types.AdminRole) : Bool { + // Controllers automatically have all permissions + if (Principal.isController(principal)) { + return true; + }; + + // Check for assigned role + let principalText = Principal.toText(principal); + switch (getAdminRole(principalText)) { + case (null) { return false; }; + case (?assignment) { + switch (assignment.role, requiredRole) { + // AdminUpdate includes AdminQuery + case (#AdminUpdate, #AdminQuery) { true }; + case (#AdminUpdate, #AdminUpdate) { true }; + // AdminQuery only has query permissions + case (#AdminQuery, #AdminQuery) { true }; + // All other combinations fail + case _ { false }; + }; + }; + }; + }; + + // Add an admin role assignment (controller-only) + public shared(msg) func assignAdminRole( + principal: Text, + role: Types.AdminRole, + note: Text + ) : async Types.AdminRoleAssignmentResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + let assignment : Types.AdminRoleAssignment = { + principal = principal; + role = role; + assignedBy = Principal.toText(msg.caller); + assignedAt = Nat64.fromNat(Int.abs(Time.now())); + note = note; + }; + + // Store the assignment (replaces any existing assignment for this principal) + let _ = putAdminRole(principal, assignment); + + #Ok(assignment) + }; + + // Remove an admin role assignment (controller-only) + public shared(msg) func revokeAdminRole(principal: Text) : async Types.TextResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + let removed = removeAdminRole(principal); + if (removed) { + #Ok("Admin role revoked for principal: " # principal) + } else { + #Err(#Other("No admin role found for principal: " # principal)) + } + }; + + // Get all admin role assignments (controller-only) + public shared query(msg) func getAdminRoles() : async Types.AdminRoleAssignmentsResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + #Ok(getAllAdminRoles()) + }; + + //------------------------------------------------------------------------- + // -------------------------------------------------------------------------- // Orthogonal Persisted Data storage - + // The minimum cycle balance we want to maintain stable let CYCLE_BALANCE_MINIMUM = 250 * Constants.CYCLES_BILLION; @@ -323,7 +430,11 @@ actor class MainerAgentCtrlbCanister() = this { }; public query (msg) func getIssueFlagsAdmin() : async Types.IssueFlagsRetrievalResult { - if (not Principal.isController(msg.caller)) { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + // Check if caller has AdminQuery permission + if (not hasAdminRole(msg.caller, #AdminQuery)) { return #Err(#Unauthorized); }; let response : Types.IssueFlagsRecord = { @@ -352,7 +463,11 @@ actor class MainerAgentCtrlbCanister() = this { }; public query (msg) func getMainerStatisticsAdmin() : async Types.StatisticsRetrievalResult { - if (not Principal.isController(msg.caller)) { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + // Check if caller has AdminQuery permission + if (not hasAdminRole(msg.caller, #AdminQuery)) { return #Err(#Unauthorized); }; var cyclesBurnRateToReturn : Types.CyclesBurnRate = CYCLES_BURN_RATE_DEFAULT; @@ -2310,13 +2425,15 @@ actor class MainerAgentCtrlbCanister() = this { mainerCreatorCanistersStorageStable := Iter.toArray(mainerCreatorCanistersStorage.entries()); shareAgentCanistersStorageStable := Iter.toArray(shareAgentCanistersStorage.entries()); userToShareAgentsStorageStable := Iter.toArray(userToShareAgentsStorage.entries()); - + // Convert Buffer to [Text] for stable storage let llmCanisterIds = Buffer.Buffer(llmCanisters.size()); for (llmCanister in llmCanisters.vals()) { llmCanisterIds.add(Principal.toText(Principal.fromActor(llmCanister))); }; llmCanistersStable := Buffer.toArray(llmCanisterIds); + + adminRoleAssignmentsStable := Iter.toArray(adminRoleAssignmentsStorage.entries()); }; system func postupgrade() { @@ -2326,7 +2443,7 @@ actor class MainerAgentCtrlbCanister() = this { shareAgentCanistersStorageStable := []; userToShareAgentsStorage := HashMap.fromIter(Iter.fromArray(userToShareAgentsStorageStable), userToShareAgentsStorageStable.size(), Principal.equal, Principal.hash); userToShareAgentsStorageStable := []; - + // Reconstruct Buffer from [Text] llmCanisters := Buffer.Buffer(llmCanistersStable.size()); for (canisterId in llmCanistersStable.vals()) { @@ -2335,6 +2452,9 @@ actor class MainerAgentCtrlbCanister() = this { }; llmCanistersStable := []; + adminRoleAssignmentsStorage := HashMap.fromIter(Iter.fromArray(adminRoleAssignmentsStable), adminRoleAssignmentsStable.size(), Text.equal, Text.hash); + adminRoleAssignmentsStable := []; + // Reset reporting variable for timer action1RegularityInSeconds := 0; // Timer is not yet set (They don't persist across upgrades) }; From 457af237531088f653d5f97749a2cbcee46807b0 Mon Sep 17 00:00:00 2001 From: icpp Date: Wed, 19 Nov 2025 09:19:08 -0500 Subject: [PATCH 22/29] Refactors assignAdminRole to use input record --- src/GameState/src/Main.mo | 14 +++++--------- src/common/Types.mo | 7 +++++++ src/mAIner/src/Main.mo | 14 +++++--------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index d033b3a..0b6fa7c 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -2023,25 +2023,21 @@ actor class GameStateCanister() = this { }; // Add an admin role assignment (controller-only) - public shared(msg) func assignAdminRole( - principal: Text, - role: Types.AdminRole, - note: Text - ) : async Types.AdminRoleAssignmentResult { + public shared(msg) func assignAdminRole(input : Types.AssignAdminRoleInputRecord) : async Types.AdminRoleAssignmentResult { if (not Principal.isController(msg.caller)) { return #Err(#Unauthorized); }; let assignment : Types.AdminRoleAssignment = { - principal = principal; - role = role; + principal = input.principal; + role = input.role; assignedBy = Principal.toText(msg.caller); assignedAt = Nat64.fromNat(Int.abs(Time.now())); - note = note; + note = input.note; }; // Store the assignment (replaces any existing assignment for this principal) - let _ = putAdminRole(principal, assignment); + let _ = putAdminRole(input.principal, assignment); #Ok(assignment) }; diff --git a/src/common/Types.mo b/src/common/Types.mo index 8cd6cf1..2fd281a 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -41,6 +41,13 @@ module Types { note : Text; }; + // Input record for admin role assignment + public type AssignAdminRoleInputRecord = { + principal : Text; + role : AdminRole; + note : Text; + }; + // Result types for admin endpoints public type AdminRoleAssignmentResult = Result; public type AdminRoleAssignmentsResult = Result<[AdminRoleAssignment], ApiError>; diff --git a/src/mAIner/src/Main.mo b/src/mAIner/src/Main.mo index fc99bdf..d3fefcc 100644 --- a/src/mAIner/src/Main.mo +++ b/src/mAIner/src/Main.mo @@ -329,25 +329,21 @@ actor class MainerAgentCtrlbCanister() = this { }; // Add an admin role assignment (controller-only) - public shared(msg) func assignAdminRole( - principal: Text, - role: Types.AdminRole, - note: Text - ) : async Types.AdminRoleAssignmentResult { + public shared(msg) func assignAdminRole(input : Types.AssignAdminRoleInputRecord) : async Types.AdminRoleAssignmentResult { if (not Principal.isController(msg.caller)) { return #Err(#Unauthorized); }; let assignment : Types.AdminRoleAssignment = { - principal = principal; - role = role; + principal = input.principal; + role = input.role; assignedBy = Principal.toText(msg.caller); assignedAt = Nat64.fromNat(Int.abs(Time.now())); - note = note; + note = input.note; }; // Store the assignment (replaces any existing assignment for this principal) - let _ = putAdminRole(principal, assignment); + let _ = putAdminRole(input.principal, assignment); #Ok(assignment) }; From c68fee321f79dbb562534a7d02c49917265bf909 Mon Sep 17 00:00:00 2001 From: icpp Date: Wed, 19 Nov 2025 14:01:03 -0500 Subject: [PATCH 23/29] Admin RBAC for API canister --- src/Api/src/Main.mo | 115 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/src/Api/src/Main.mo b/src/Api/src/Main.mo index 314faa1..362b498 100644 --- a/src/Api/src/Main.mo +++ b/src/Api/src/Main.mo @@ -7,6 +7,7 @@ import Time "mo:base/Time"; import Float "mo:base/Float"; import Iter "mo:base/Iter"; import Option "mo:base/Option"; +import Nat64 "mo:base/Nat64"; import Types "../../common/Types"; @@ -67,6 +68,66 @@ persistent actor class ApiCanister() = this { stable var dailyMetricsEntries : [(Text, Types.DailyMetric)] = []; transient var dailyMetrics = HashMap.HashMap(10, Text.equal, Text.hash); + //------------------------------------------------------------------------- + // Admin RBAC Storage + //------------------------------------------------------------------------- + stable var adminRoleAssignmentsStable : [(Text, Types.AdminRoleAssignment)] = []; + transient var adminRoleAssignmentsStorage : HashMap.HashMap = HashMap.HashMap(0, Text.equal, Text.hash); + + private func putAdminRole(principal : Text, assignment : Types.AdminRoleAssignment) : Bool { + adminRoleAssignmentsStorage.put(principal, assignment); + return true; + }; + + private func getAdminRole(principal : Text) : ?Types.AdminRoleAssignment { + switch (adminRoleAssignmentsStorage.get(principal)) { + case (null) { return null; }; + case (?assignment) { return ?assignment; }; + }; + }; + + private func removeAdminRole(principal : Text) : Bool { + switch (adminRoleAssignmentsStorage.get(principal)) { + case (null) { return false; }; + case (?assignment) { + let removeResult = adminRoleAssignmentsStorage.remove(principal); + return true; + }; + }; + }; + + private func getAllAdminRoles() : [Types.AdminRoleAssignment] { + let assignments : Iter.Iter = adminRoleAssignmentsStorage.vals(); + return Iter.toArray(assignments); + }; + + // Helper function to check admin permissions + private func hasAdminRole(principal : Principal, requiredRole : Types.AdminRole) : Bool { + // Controllers automatically have all permissions + if (Principal.isController(principal)) { + return true; + }; + + // Check for assigned role + let principalText = Principal.toText(principal); + switch (getAdminRole(principalText)) { + case (null) { return false; }; + case (?assignment) { + switch (assignment.role, requiredRole) { + // AdminUpdate includes AdminQuery + case (#AdminUpdate, #AdminQuery) { true }; + case (#AdminUpdate, #AdminUpdate) { true }; + // AdminQuery only has query permissions + case (#AdminQuery, #AdminQuery) { true }; + // All other combinations fail + case _ { false }; + }; + }; + }; + }; + + //------------------------------------------------------------------------- + // ------------------------------------------------------------------------------- // Token Rewards Data (Static) @@ -470,6 +531,53 @@ persistent actor class ApiCanister() = this { } }; + // ------------------------------------------------------------------------------- + // Admin RBAC Management Endpoints + // ------------------------------------------------------------------------------- + + // Add an admin role assignment (controller-only) + public shared(msg) func assignAdminRole(input : Types.AssignAdminRoleInputRecord) : async Types.AdminRoleAssignmentResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + let assignment : Types.AdminRoleAssignment = { + principal = input.principal; + role = input.role; + assignedBy = Principal.toText(msg.caller); + assignedAt = Nat64.fromNat(Int.abs(Time.now())); + note = input.note; + }; + + // Store the assignment (replaces any existing assignment for this principal) + let _ = putAdminRole(input.principal, assignment); + + #Ok(assignment) + }; + + // Remove an admin role assignment (controller-only) + public shared(msg) func revokeAdminRole(principal: Text) : async Types.TextResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + let removed = removeAdminRole(principal); + if (removed) { + #Ok("Admin role revoked for principal: " # principal) + } else { + #Err(#Other("No admin role found for principal: " # principal)) + } + }; + + // Get all admin role assignments (controller-only) + public shared query(msg) func getAdminRoles() : async Types.AdminRoleAssignmentsResult { + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + + #Ok(getAllAdminRoles()) + }; + // ------------------------------------------------------------------------------- // Admin CRUD Endpoints @@ -477,7 +585,8 @@ persistent actor class ApiCanister() = this { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); }; - if (not (Principal.isController(msg.caller) or Principal.equal(msg.caller, Principal.fromText(MASTER_CANISTER_ID)))) { + // Check if caller has AdminUpdate permission or is the master canister + if (not (hasAdminRole(msg.caller, #AdminUpdate) or Principal.equal(msg.caller, Principal.fromText(MASTER_CANISTER_ID)))) { return #Err(#Unauthorized); }; @@ -808,10 +917,14 @@ persistent actor class ApiCanister() = this { // System upgrade hooks system func preupgrade() { dailyMetricsEntries := Iter.toArray(dailyMetrics.entries()); + adminRoleAssignmentsStable := Iter.toArray(adminRoleAssignmentsStorage.entries()); }; system func postupgrade() { dailyMetrics := HashMap.fromIter(dailyMetricsEntries.vals(), dailyMetricsEntries.size(), Text.equal, Text.hash); dailyMetricsEntries := []; + + adminRoleAssignmentsStorage := HashMap.fromIter(Iter.fromArray(adminRoleAssignmentsStable), adminRoleAssignmentsStable.size(), Text.equal, Text.hash); + adminRoleAssignmentsStable := []; }; }; \ No newline at end of file From 868f7a81b423606704cb967abd4a49971101869b Mon Sep 17 00:00:00 2001 From: icpp Date: Wed, 19 Nov 2025 14:04:25 -0500 Subject: [PATCH 24/29] Updates daily metric creation to allow overwrites --- src/Api/src/Main.mo | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Api/src/Main.mo b/src/Api/src/Main.mo index 362b498..a029e75 100644 --- a/src/Api/src/Main.mo +++ b/src/Api/src/Main.mo @@ -589,23 +589,16 @@ persistent actor class ApiCanister() = this { if (not (hasAdminRole(msg.caller, #AdminUpdate) or Principal.equal(msg.caller, Principal.fromText(MASTER_CANISTER_ID)))) { return #Err(#Unauthorized); }; - + // Validate date format if (not isValidDateFormat(input.date)) { return #Err(#Other("Invalid date format. Use YYYY-MM-DD")); }; - - // Check if metric already exists - switch (dailyMetrics.get(input.date)) { - case (?_existing) { - return #Err(#Other("Metric for date " # input.date # " already exists")); - }; - case null { - let metric = inputToDailyMetric(input, false); - dailyMetrics.put(input.date, metric); - return #Ok(metric); - }; - }; + + // Create or replace metric (replaces existing if it exists) + let metric = inputToDailyMetric(input, false); + dailyMetrics.put(input.date, metric); + return #Ok(metric); }; public shared (msg) func updateDailyMetricAdmin(params: Types.UpdateDailyMetricAdminInput) : async Types.DailyMetricResult { From 739de64f0c756e8d25a5625ec4eaabafc310e755 Mon Sep 17 00:00:00 2001 From: patnorris Date: Fri, 21 Nov 2025 13:15:13 +0100 Subject: [PATCH 25/29] Add error handling for topups of frozen mAIner canisters --- src/GameState/src/Main.mo | 45 +++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 0b6fa7c..2687d42 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -7061,15 +7061,48 @@ actor class GameStateCanister() = this { }; case (#Ok(handleResult)) { D.print("GameState: topUpCyclesForMainerAgent - handleResult: " # debug_show(handleResult)); - // TODO - Implementation: credit mAIner agent with cycles (the user paid for) + // Credit mAIner agent with cycles (the user paid for) try { let Mainer_Actor : Types.MainerAgentCtrlbCanister = actor (userMainerEntry.address); D.print("GameState: topUpCyclesForMainerAgent - calling Cycles.add for = " # debug_show(handleResult.cyclesForMainer) # " Cycles"); - Cycles.add(handleResult.cyclesForMainer); - - D.print("GameState: topUpCyclesForMainerAgent - calling Mainer_Actor.addCycles"); - let addCyclesResponse = await Mainer_Actor.addCycles(); + var addCyclesResponse : Types.AddCyclesResult = #Err(#Other("addCycles call didn't work")); + try { + Cycles.add(handleResult.cyclesForMainer); + D.print("GameState: topUpCyclesForMainerAgent - calling Mainer_Actor.addCycles"); + addCyclesResponse := await Mainer_Actor.addCycles(); + } catch (e) { + D.print("GameState: topUpCyclesForMainerAgent - Failed to call addCycles on mAIner: " # debug_show(mainerTopUpInfo) # Error.message(e)); + // try again below + }; D.print("GameState: topUpCyclesForMainerAgent - addCyclesResponse: " # debug_show(addCyclesResponse)); + switch (addCyclesResponse) { + case (#Err(error)) { + D.print("GameState: topUpCyclesForMainerAgent - addCyclesResponse error: " # debug_show(error)); + // mAIner canister might be frozen due to too low cycles, so unfreeze canister by sending some cycles via the system API first + let cyclesToUnfreezeMainer = 100 * Constants.CYCLES_BILLION; + Cycles.add(cyclesToUnfreezeMainer); + let deposit_cycles_args = { canister_id : Principal = Principal.fromText(userMainerEntry.address); }; + let _ = await IC0.deposit_cycles(deposit_cycles_args); + D.print("GameState: topUpCyclesForMainerAgent - Sent cycles to mAIner via IC0.deposit_cycles: " # debug_show(deposit_cycles_args)); + // then send any remaining cycles via the dedicated endpoint + if (handleResult.cyclesForMainer > cyclesToUnfreezeMainer) { + let remainingCycles = handleResult.cyclesForMainer - cyclesToUnfreezeMainer; + D.print("GameState: topUpCyclesForMainerAgent - calling addCycles on mAIner with remainingCycles: " # debug_show(remainingCycles)); + Cycles.add(remainingCycles); + addCyclesResponse := await Mainer_Actor.addCycles(); + } else { + // Record successful cycles deposit + let sentCyclesResult : Types.AddCyclesRecord = { + added : Bool = true; + amount : Nat = cyclesToUnfreezeMainer; + }; + addCyclesResponse := #Ok(sentCyclesResult); + }; + }; + case (_) { + // continue as addCycles was successful + }; + }; switch (addCyclesResponse) { case (#Err(error)) { D.print("GameState: topUpCyclesForMainerAgent - addCyclesResponse FailedOperation: " # debug_show(error)); @@ -7078,7 +7111,7 @@ actor class GameStateCanister() = this { case (#Ok(addCyclesResult)) { D.print("GameState: topUpCyclesForMainerAgent - addCyclesResult: " # debug_show(addCyclesResult)); //TODO - Design: decide whether a top up history should be kept - // TODO - Implementation: track redeemed transaction blocks to ensure no double spending + // Track redeemed transaction blocks to ensure no double spending switch (putRedeemedTransactionBlock(newTransactionEntry)) { case (false) { // TODO - Error Handling: likely retry From 9281c20a9d49d95999117ee9ed9532c9f8a49fa1 Mon Sep 17 00:00:00 2001 From: patnorris Date: Fri, 21 Nov 2025 14:54:55 +0100 Subject: [PATCH 26/29] Add admin function to complete topups --- src/GameState/src/Main.mo | 180 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 2687d42..c197318 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -7135,6 +7135,186 @@ actor class GameStateCanister() = this { }; }; + // Function for admin to complete a user's topup (cycles for an existing mAIner agent) + public shared (msg) func completeTopUpCyclesForMainerAgentAdmin(mainerTopUpInfo : Types.MainerAgentTopUpInput) : async Types.MainerAgentCanisterResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + if (not Principal.isController(msg.caller)) { + return #Err(#Unauthorized); + }; + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - mainerTopUpInfo: "# debug_show(mainerTopUpInfo)); + + // TODO: put this check back in place + // Ensure this transaction block hasn't been redeemed yet (no double spending) + let transactionToVerify = mainerTopUpInfo.paymentTransactionBlockId; + /* switch (checkExistingTransactionBlock(transactionToVerify)) { + case (false) { + // new transaction, continue + }; + case (true) { + // already redeem transaction + return #Err(#Other("Already redeemd this transaction block")); // no double spending + }; + }; */ + + // Sanity checks on provided mAIner info + let mainerInfo : Types.OfficialMainerAgentCanister = mainerTopUpInfo.mainerAgent; + if (Principal.equal(mainerInfo.ownedBy, msg.caller)) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - 01 "); + return #Err(#Unauthorized); + }; + if (mainerInfo.address == "") { + // The mAIner Controller canister address is needed + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - 02 "); + return #Err(#InvalidId); + }; + switch (mainerInfo.canisterType) { + case (#MainerAgent(_)) { + // continue + }; + case (_) { return #Err(#Other("Unsupported")); } + }; + + // Verify existing mAIner entry + switch (getUserMainerAgents(mainerInfo.ownedBy)) { + case (null) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - 03 "); + return #Err(#Unauthorized); + }; + case (?userMainerEntries) { + switch (List.find(userMainerEntries, func(mainerEntry: Types.OfficialMainerAgentCanister) : Bool { mainerEntry.address == mainerInfo.address } )) { + case (null) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - 04 "); + return #Err(#InvalidId); + }; + case (?userMainerEntry) { + // Sanity checks on userMainerEntry (i.e. address provided is correct and matches entry info) + switch (userMainerEntry.canisterType) { + case (#MainerAgent(_)) { + // continue + }; + case (_) { return #Err(#Other("Unsupported")); } + }; + + // Verify user had paid for this topup via the TransactionBlockId + var verifiedPayment : Bool = false; + var amountPaid : Nat = 0; + let redeemedFor : Types.RedeemedForOptions = #MainerTopUp(userMainerEntry.address); + let creationTimestamp : Nat64 = Nat64.fromNat(Int.abs(Time.now())); + let transactionEntryToVerify : Types.RedeemedTransactionBlock = { + paymentTransactionBlockId : Nat64 = mainerTopUpInfo.paymentTransactionBlockId; + creationTimestamp : Nat64 = creationTimestamp; + redeemedBy : Principal = msg.caller; + redeemedFor : Types.RedeemedForOptions = redeemedFor; + amount : Nat = amountPaid; // to be updated + }; + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - transactionEntryToVerify: "# debug_show(transactionEntryToVerify)); + let verificationResponse = await verifyIncomingPayment(transactionEntryToVerify); + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - verificationResponse: "# debug_show(verificationResponse)); + switch (verificationResponse) { + case (#Ok(verificationResult)) { + verifiedPayment := verificationResult.verified; + amountPaid := verificationResult.amountPaid; + }; + case (_) { + return #Err(#Other("Payment verification failed")); + }; + }; + if (not verifiedPayment) { + return #Err(#Other("Payment couldn't be verified")); + }; + + let newTransactionEntry : Types.RedeemedTransactionBlock = { + paymentTransactionBlockId : Nat64 = mainerTopUpInfo.paymentTransactionBlockId; + creationTimestamp : Nat64 = creationTimestamp; + redeemedBy : Principal = msg.caller; + redeemedFor : Types.RedeemedForOptions = redeemedFor; + amount : Nat = amountPaid; + }; + let handleResponse : Types.HandleIncomingFundsResult = await handleIncomingFunds(newTransactionEntry); + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - handleResponse: " # debug_show(handleResponse)); + switch (handleResponse) { + case (#Err(error)) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - handleResponse FailedOperation: " # debug_show(error)); + return #Err(#FailedOperation); + }; + case (#Ok(handleResult)) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - handleResult: " # debug_show(handleResult)); + // Credit mAIner agent with cycles (the user paid for) + try { + let Mainer_Actor : Types.MainerAgentCtrlbCanister = actor (userMainerEntry.address); + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - calling Cycles.add for = " # debug_show(handleResult.cyclesForMainer) # " Cycles"); + var addCyclesResponse : Types.AddCyclesResult = #Err(#Other("addCycles call didn't work")); + try { + Cycles.add(handleResult.cyclesForMainer); + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - calling Mainer_Actor.addCycles"); + addCyclesResponse := await Mainer_Actor.addCycles(); + } catch (e) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - Failed to call addCycles on mAIner: " # debug_show(mainerTopUpInfo) # Error.message(e)); + // try again below + }; + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - addCyclesResponse: " # debug_show(addCyclesResponse)); + switch (addCyclesResponse) { + case (#Err(error)) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - addCyclesResponse error: " # debug_show(error)); + // mAIner canister might be frozen due to too low cycles, so unfreeze canister by sending some cycles via the system API first + let cyclesToUnfreezeMainer = 100 * Constants.CYCLES_BILLION; + Cycles.add(cyclesToUnfreezeMainer); + let deposit_cycles_args = { canister_id : Principal = Principal.fromText(userMainerEntry.address); }; + let _ = await IC0.deposit_cycles(deposit_cycles_args); + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - Sent cycles to mAIner via IC0.deposit_cycles: " # debug_show(deposit_cycles_args)); + // then send any remaining cycles via the dedicated endpoint + if (handleResult.cyclesForMainer > cyclesToUnfreezeMainer) { + let remainingCycles = handleResult.cyclesForMainer - cyclesToUnfreezeMainer; + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - calling addCycles on mAIner with remainingCycles: " # debug_show(remainingCycles)); + Cycles.add(remainingCycles); + addCyclesResponse := await Mainer_Actor.addCycles(); + } else { + // Record successful cycles deposit + let sentCyclesResult : Types.AddCyclesRecord = { + added : Bool = true; + amount : Nat = cyclesToUnfreezeMainer; + }; + addCyclesResponse := #Ok(sentCyclesResult); + }; + }; + case (_) { + // continue as addCycles was successful + }; + }; + switch (addCyclesResponse) { + case (#Err(error)) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - addCyclesResponse FailedOperation: " # debug_show(error)); + return #Err(#FailedOperation); + }; + case (#Ok(addCyclesResult)) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - addCyclesResult: " # debug_show(addCyclesResult)); + //TODO - Design: decide whether a top up history should be kept + // Track redeemed transaction blocks to ensure no double spending + switch (putRedeemedTransactionBlock(newTransactionEntry)) { + case (false) { + // TODO - Error Handling: likely retry + }; + case (true) { + // continue + }; + }; + return #Ok(userMainerEntry); + }; + }; + } catch (e) { + D.print("GameState: completeTopUpCyclesForMainerAgentAdmin - Failed to credit cycles to mAIner: " # debug_show(mainerTopUpInfo) # Error.message(e)); + return #Err(#Other("GameState: completeTopUpCyclesForMainerAgentAdmin - Failed to credit cycles to mAIner: " # debug_show(mainerTopUpInfo) # Error.message(e))); + }; + }; + }; + }; + }; + }; + }; + }; + // Function for user to top up cycles of an existing mAIner agent with FUNNAI public shared (msg) func topUpCyclesForMainerAgentWithFunnai(mainerTopUpInfo : Types.MainerAgentTopUpInput) : async Types.MainerAgentCanisterResult { if (Principal.isAnonymous(msg.caller)) { From f03fac180ac4283e8f0150a1c1f35ff191f05c01 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 26 Nov 2025 11:12:04 +0000 Subject: [PATCH 27/29] clean up nft functions --- src/GameState/src/Main.mo | 158 ++++++++------------------------------ src/GameState/src/NFT.mo | 150 ++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 124 deletions(-) create mode 100644 src/GameState/src/NFT.mo diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 0209db8..03fe6a2 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -30,6 +30,7 @@ import Constants "../../common/Constants"; import CMC "../../common/cycles-minting-canister-interface"; import Utils "Utils"; +import NFT "NFT"; actor class GameStateCanister() = this { @@ -8257,126 +8258,42 @@ actor class GameStateCanister() = this { }; }; */ -// NFT compatibility for mAIners listed on marketplace (ICRC7 and ICRC37) - // Static endpoints, see example: https://github.com/PanIndustrial-Org/icrc_nft.mo/blob/main/example/main.mo - let icrc7Symbol : Text = "MAINERS"; - let icrc7Name : Text = "funnAI mAIners"; - let icrc7Description : Text = "mAIner AI agents listed on the funnAI marketplace."; - let icrc7Logo : Text = "https://funnai.onicai.com/funnai.webp"; - +// ============================================================================ + // NFT COMPATIBILITY FUNCTIONS (ICRC-7 and ICRC-37) + // See NFT.mo for additional NFT functions not actively used by marketplace + // ============================================================================ + + // --- Static metadata functions (delegated to NFT module) --- public query func icrc7_symbol() : async Text { - return icrc7Symbol; + return NFT.symbol(); }; public query func icrc7_name() : async Text { - return icrc7Name; + return NFT.name(); }; public query func icrc7_description() : async ?Text { - return ?icrc7Description; + return NFT.description(); }; public query func icrc7_logo() : async ?Text { - return ?icrc7Logo; + return NFT.logo(); }; - /* public query func icrc7_max_memo_size() : async ?Nat { - return ?100; // TODO: placeholder + public query func icrc7_collection_metadata() : async [(Text, ICRC7.Value)] { + return NFT.collectionMetadata(); }; - public query func icrc7_tx_window() : async ?Nat { - return ?100; // TODO: placeholder + public query func icrc10_supported_standards() : async ICRC7.SupportedStandards { + return NFT.supportedStandards(); }; - public query func icrc7_permitted_drift() : async ?Nat { - return ?100; // TODO: placeholder - }; */ - + // --- Marketplace-essential ICRC-7 functions --- + public query func icrc7_total_supply() : async Nat { return marketplaceListedMainerAgentsStorage.size(); }; - public query func icrc7_supply_cap() : async ?Nat { - let currentNumberOfMainers = getNumberMainerAgents(#ShareAgent); - return ?currentNumberOfMainers; - }; - - /* public query func icrc37_max_approvals_per_token_or_collection() : async ?Nat { - return ?1; // TODO: placeholder - }; - - public query func icrc7_max_query_batch_size() : async ?Nat { - return ?1; // TODO: placeholder - }; - - public query func icrc7_max_update_batch_size() : async ?Nat { - return ?1; // TODO: placeholder - }; - - public query func icrc7_default_take_value() : async ?Nat { - return ?100; // TODO: placeholder - }; - - public query func icrc7_max_take_value() : async ?Nat { - return ?100; // TODO: placeholder - }; - - public query func icrc7_atomic_batch_transfers() : async ?Bool { - return ?true; // TODO: placeholder - }; - - public query func icrc37_max_revoke_approvals() : async ?Nat { - return ?1; // TODO: placeholder - }; */ - - public query func icrc7_collection_metadata() : async [(Text, ICRC7.Value)] { - let metadata : [(Text, ICRC7.Value)] = [ - ("ICRC-7:Symbol", #Text(icrc7Symbol)), - ("ICRC-7:Name", #Text(icrc7Name)), - ("ICRC-7:Description", #Text(icrc7Description)), - ("ICRC-7:Logo", #Text(icrc7Logo)) - ]; - - return metadata; - }; - - /* public query func icrc7_owner_of(token_ids: OwnerOfRequest) : async OwnerOfResponse { - return null; // TODO: placeholder: only allow 1 token id and retrieve info for it - }; */ - - public query (msg) func icrc7_balance_of(accounts: [TokenLedger.Account]) : async [Nat] { - // Only allows 1 account and retrieves info for it - if (Principal.isAnonymous(msg.caller)) { - return [0]; - }; - if (accounts.size() != 1) { - return [0]; - }; - switch (getMarketplaceListedMainersForUser(accounts[0].owner)) { - case (null) { return [0]; }; - case (?userCanistersList) { - let numberOfListings : Nat = List.size(userCanistersList); - return [numberOfListings]; - }; - }; - }; - - public query func icrc7_tokens(prev: ?Nat, take: ?Nat) : async [Nat] { - // Create a list of Nat with entries from 0 to marketplaceListedMainerAgentsStorage's size minus 1 - let total = marketplaceListedMainerAgentsStorage.size(); - if (total == 0) { - return []; - }; - - var ids : List.List = List.nil(); - for (i in Iter.range(0, total - 1)) { - ids := List.push(i, ids); - }; - let idsArray = List.toArray(ids); - - return idsArray; - }; - public query func icrc7_token_metadata(token_ids: [Nat]) : async [?[(Text, ICRC7.Value)]]{ // Retrieve all mAIner marketplace listings let listings : [Types.MainerMarketplaceListing] = getAllMarketplaceListedMainers(); @@ -8398,40 +8315,33 @@ actor class GameStateCanister() = this { return out; }; - /* public query func icrc7_tokens_of(account: Account, prev: ?Nat, take: ?Nat) : async [Nat] { - // Retrieve all listed mAIners for account + // --- NFT functions moved to NFT.mo (kept here for interface compatibility, may be removed later) --- + + public query func icrc7_supply_cap() : async ?Nat { + let currentNumberOfMainers = getNumberMainerAgents(#ShareAgent); + return ?currentNumberOfMainers; + }; + + public query (msg) func icrc7_balance_of(accounts: [TokenLedger.Account]) : async [Nat] { + // Only allows 1 account and retrieves info for it if (Principal.isAnonymous(msg.caller)) { return [0]; }; - switch (getMarketplaceListedMainersForUser(account.owner)) { + if (accounts.size() != 1) { + return [0]; + }; + switch (getMarketplaceListedMainersForUser(accounts[0].owner)) { case (null) { return [0]; }; case (?userCanistersList) { let numberOfListings : Nat = List.size(userCanistersList); return [numberOfListings]; }; }; - }; */ - - /* public query func icrc37_is_approved(args: [IsApprovedArg]) : async [Bool] { - return [false]; // TODO: only allow 1 token id and check if listed - }; */ - - /* public query func icrc37_get_token_approvals(token_ids: [Nat], prev: ?TokenApproval, take: ?Nat) : async [TokenApproval] { - - return icrc37().get_token_approvals(token_ids, prev, take); - }; */ - - /* public query func icrc37_get_collection_approvals(owner : Account, prev: ?CollectionApproval, take: ?Nat) : async [CollectionApproval] { - - return icrc37().get_collection_approvals(owner, prev, take); - }; */ + }; - public query func icrc10_supported_standards() : async ICRC7.SupportedStandards { - //TODO: ICRC-10? - return [ - {name = "ICRC-7"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-7"}, - {name = "ICRC-10"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-10"}, - {name = "ICRC-37"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-37"}]; + public query func icrc7_tokens(prev: ?Nat, take: ?Nat) : async [Nat] { + let total = marketplaceListedMainerAgentsStorage.size(); + return NFT.generateTokenIds(total); }; public shared(msg) func icrc37_approve_tokens(args: [ICRC37.Service.ApproveTokenArg]) : async [?ICRC37.Service.ApproveTokenResult] { diff --git a/src/GameState/src/NFT.mo b/src/GameState/src/NFT.mo new file mode 100644 index 0000000..7dc5c41 --- /dev/null +++ b/src/GameState/src/NFT.mo @@ -0,0 +1,150 @@ +// NFT.mo - ICRC-7 NFT compatibility functions (not actively used by marketplace) +// These functions are kept for potential future NFT compatibility but are not required +// for the current marketplace functionality. +// +// The marketplace uses: +// - icrc37_approve_tokens (listing) +// - icrc37_revoke_token_approvals (cancel listing) +// - icrc37_transfer_from (complete purchase) +// - icrc7_total_supply (listing count) +// - icrc7_token_metadata (listing details) +// +// The functions below are standard ICRC-7 NFT interface functions that could be +// re-enabled if full NFT compatibility is needed in the future. + +import Principal "mo:base/Principal"; +import List "mo:base/List"; +import Iter "mo:base/Iter"; +import ICRC7 "mo:icrc7-mo"; + +module { + // Static NFT collection metadata + public let icrc7Symbol : Text = "MAINERS"; + public let icrc7Name : Text = "funnAI mAIners"; + public let icrc7Description : Text = "mAIner AI agents listed on the funnAI marketplace."; + public let icrc7Logo : Text = "https://funnai.onicai.com/funnai.webp"; + + // ICRC-7 Static metadata functions + public func symbol() : Text { + return icrc7Symbol; + }; + + public func name() : Text { + return icrc7Name; + }; + + public func description() : ?Text { + return ?icrc7Description; + }; + + public func logo() : ?Text { + return ?icrc7Logo; + }; + + // Collection metadata + public func collectionMetadata() : [(Text, ICRC7.Value)] { + let metadata : [(Text, ICRC7.Value)] = [ + ("ICRC-7:Symbol", #Text(icrc7Symbol)), + ("ICRC-7:Name", #Text(icrc7Name)), + ("ICRC-7:Description", #Text(icrc7Description)), + ("ICRC-7:Logo", #Text(icrc7Logo)) + ]; + return metadata; + }; + + // Supported standards + public func supportedStandards() : ICRC7.SupportedStandards { + return [ + {name = "ICRC-7"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-7"}, + {name = "ICRC-10"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-10"}, + {name = "ICRC-37"; url = "https://github.com/dfinity/ICRC/ICRCs/ICRC-37"} + ]; + }; + + // Generate token ID list from 0 to total-1 + public func generateTokenIds(total: Nat) : [Nat] { + if (total == 0) { + return []; + }; + var ids : List.List = List.nil(); + for (i in Iter.range(0, total - 1)) { + ids := List.push(i, ids); + }; + return List.toArray(ids); + }; + + // ============================================================================ + // COMMENTED OUT FUNCTIONS - Kept for reference if full NFT compatibility needed + // ============================================================================ + + /* + // These were placeholder implementations that may be needed for full ICRC-7 compliance + + public query func icrc7_max_memo_size() : async ?Nat { + return ?100; + }; + + public query func icrc7_tx_window() : async ?Nat { + return ?100; + }; + + public query func icrc7_permitted_drift() : async ?Nat { + return ?100; + }; + + public query func icrc37_max_approvals_per_token_or_collection() : async ?Nat { + return ?1; + }; + + public query func icrc7_max_query_batch_size() : async ?Nat { + return ?1; + }; + + public query func icrc7_max_update_batch_size() : async ?Nat { + return ?1; + }; + + public query func icrc7_default_take_value() : async ?Nat { + return ?100; + }; + + public query func icrc7_max_take_value() : async ?Nat { + return ?100; + }; + + public query func icrc7_atomic_batch_transfers() : async ?Bool { + return ?true; + }; + + public query func icrc37_max_revoke_approvals() : async ?Nat { + return ?1; + }; + + // Owner lookup - would need access to marketplace storage + public query func icrc7_owner_of(token_ids: OwnerOfRequest) : async OwnerOfResponse { + return null; // Only allow 1 token id and retrieve info for it + }; + + // Tokens owned by account - would need access to marketplace storage + public query func icrc7_tokens_of(account: Account, prev: ?Nat, take: ?Nat) : async [Nat] { + // Retrieve all listed mAIners for account + return []; + }; + + // Approval check - would need access to marketplace storage + public query func icrc37_is_approved(args: [IsApprovedArg]) : async [Bool] { + return [false]; // Only allow 1 token id and check if listed + }; + + // Token approvals - would need access to marketplace storage + public query func icrc37_get_token_approvals(token_ids: [Nat], prev: ?TokenApproval, take: ?Nat) : async [TokenApproval] { + return []; + }; + + // Collection approvals - would need access to marketplace storage + public query func icrc37_get_collection_approvals(owner : Account, prev: ?CollectionApproval, take: ?Nat) : async [CollectionApproval] { + return []; + }; + */ +}; + From da5f54006e64d03022ff0891536a8386202cb559 Mon Sep 17 00:00:00 2001 From: Nuno Lopes Date: Wed, 26 Nov 2025 11:17:17 +0000 Subject: [PATCH 28/29] add transaction history --- src/GameState/src/Main.mo | 24 ++++++++++++++++++++++++ src/common/Types.mo | 7 +++++++ 2 files changed, 31 insertions(+) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 03fe6a2..ca777c6 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -9002,6 +9002,30 @@ actor class GameStateCanister() = this { }; }; + // Get user's marketplace transaction history (buys and sells) + public query (msg) func getUserMarketplaceTransactionHistory() : async Types.MarketplaceTransactionHistoryResult { + if (Principal.isAnonymous(msg.caller)) { + return #Err(#Unauthorized); + }; + + var purchases : List.List = List.nil(); + var sales : List.List = List.nil(); + + for (sale in marketplaceSalesHistory.vals()) { + if (Principal.equal(sale.buyer, msg.caller)) { + purchases := List.push(sale, purchases); + }; + if (Principal.equal(sale.seller, msg.caller)) { + sales := List.push(sale, sales); + }; + }; + + return #Ok({ + purchases = List.toArray(purchases); + sales = List.toArray(sales); + }); + }; + public shared (msg) func reserveMarketplaceListedMainer(reservationInput : Types.MainerMarketplaceReservationInput) : async Types.MainerMarketplaceReservationResult { if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); diff --git a/src/common/Types.mo b/src/common/Types.mo index 5646a3d..42fdb50 100644 --- a/src/common/Types.mo +++ b/src/common/Types.mo @@ -375,6 +375,13 @@ module Types { uniqueSellers : Nat; }; + public type MarketplaceTransactionHistory = { + purchases : [MarketplaceSale]; // mAIners user bought + sales : [MarketplaceSale]; // mAIners user sold + }; + + public type MarketplaceTransactionHistoryResult = Result; + public type CanisterInput = { address : CanisterAddress; subnet : Text; From d0edbc6efe139b9898c070750e229f7e0f9c58f2 Mon Sep 17 00:00:00 2001 From: patnorris Date: Thu, 27 Nov 2025 17:40:23 +0100 Subject: [PATCH 29/29] Add comments --- src/GameState/src/Main.mo | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/GameState/src/Main.mo b/src/GameState/src/Main.mo index 657359a..fd4c7f8 100644 --- a/src/GameState/src/Main.mo +++ b/src/GameState/src/Main.mo @@ -8645,11 +8645,14 @@ actor class GameStateCanister() = this { if (not verifiedPayment) { return #Err(#Other("Payment couldn't be verified")); }; */ - - // TODO: take protocol cut (10%) and send rest to seller + + // TODO: retrieve approved ICP (mAIner price) // Transfer mAIner ownership - // Update mAIner entry + // TODO: Add the buyer as a controller of the mAIner canister via mAIner Creator (if this fails, try again, otherwise cancel the sale) + + // Update mAIner entry + // TODO: (if this fails, try again, otherwise revert the controller update and cancel the sale) let newCanisterEntry : Types.OfficialMainerAgentCanister = { address : Text = mainerEntry.address; subnet : Text = mainerEntry.subnet; @@ -8671,6 +8674,11 @@ actor class GameStateCanister() = this { let addResult : Bool = putUserMainerAgent(newCanisterEntry); D.print("GameState: icrc37_transfer_from - added to buyer: "# debug_show(addResult)); + // TODO: Remove the seller as controller from the mAIner canister via mAIner Creator (if this fails, try again, otherwise store the failure for an admin to check) + + + // TODO: if any step during the ownership transfer failed, revert any ownership changes, send back the ICP to the buyer and cancel the sale (mAIner back to listed) + // Record the sale for statistics let sale : Types.MarketplaceSale = { mainerAddress = mainerAddress; @@ -8694,6 +8702,8 @@ actor class GameStateCanister() = this { ignore userToMarketplaceReservedMainerStorage.remove(msg.caller); D.print("GameState: icrc37_transfer_from - cleared reservation for mainerAddress: "# debug_show(mainerAddress)); + // TODO: Take protocol cut (10%) from the sales amount and send the rest to the seller + return [?#Ok(getNextMainerMarketplaceTransactionId())]; }; };