diff --git a/src/deposits/Withdrawals.mo b/src/deposits/Withdrawals.mo index c6f9d34..d4140c1 100644 --- a/src/deposits/Withdrawals.mo +++ b/src/deposits/Withdrawals.mo @@ -14,6 +14,7 @@ import Result "mo:base/Result"; import Text "mo:base/Text"; import Time "mo:base/Time"; import TrieMap "mo:base/TrieMap"; +import Nat32 "mo:base/Nat32"; import NNS "./NNS"; import Neurons "./Neurons"; @@ -291,6 +292,195 @@ module { return withdrawal; }; + ////// Support for subaccounts /////////////// + + let nullBlob : Blob = "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00"; + + let ahash = ( + func account_equal(a: Account, b: Account): Bool { + a.owner == b.owner and + (switch((a.subaccount, b.subaccount)) { + case (null, null) { true }; + case(null, ?val){ not(nullBlob == val) }; + case(?val, null){ not(nullBlob == val) }; + case (?aSub, ?bSub) { Blob.equal(aSub, bSub)}; + }) + }, + func account_hash(a: Account): Nat32 { + var accumulator = Principal.hash(a.owner); + switch(a.subaccount){ + case(null){ accumulator +%= Blob.hash(nullBlob) }; + case(?val){ accumulator +%= Blob.hash(val) }; + }; + return accumulator; + } + ); + + func accountToText(a: Account): Text { + Principal.toText(a.owner) # ( + switch(a.subaccount){ + case null { "" }; + case ( ?sub ) { + if (sub == nullBlob) { "" } else{ + Nat32.toText(Blob.hash(sub)) + } + } + } + ) + }; + + private var withdrawals_account = TrieMap.TrieMap(Text.equal, Text.hash); + private var withdrawalsByUser_account = TrieMap.TrieMap>(ahash); + + type Account = {owner: Principal; subaccount: ?Blob}; + + public type Withdrawal_Account = { + id: Text; + account: Account; + createdAt: Time.Time; + expectedAt: Time.Time; + readyAt: ?Time.Time; + disbursedAt: ?Time.Time; + total: Nat64; + pending: Nat64; + available: Nat64; + disbursed: Nat64; + }; + + public func createWithdrawal_account(account: Account, amount: Nat64, delay: Int): Withdrawal_Account{ + let now = Time.now(); + let id = nextForAccountWithdrawalId(account); + let withdrawal_account: Withdrawal_Account = { + id = id; + account = account; + createdAt = now; + expectedAt = now + (delay * second); + readyAt = null; + disbursedAt = null; + total = amount; + pending = amount; + available = 0; + disbursed = 0; + }; + withdrawals_account.put(id, withdrawal_account); + pendingWithdrawals := Deque.pushBack(pendingWithdrawals, {id = id; createdAt = now}); + withdrawalsByAccountAdd(withdrawal_account.account, id); + return {withdrawal_account with account} + }; + + private func nextForAccountWithdrawalId(account: Account): Text { + let count = Option.get>( + withdrawalsByUser_account.get(account), + Buffer.Buffer(0) + ).size(); + accountToText(account) # "-" # Nat.toText(count+1) + }; + + private func withdrawalsByAccountAdd(account: Account, id: Text) { + let buf = Option.get>( + withdrawalsByUser_account.get(account), + Buffer.Buffer(1) + ); + buf.add(id); + withdrawalsByUser_account.put(account, buf); + }; + + public func withdrawalsFor_account(account: Account): [Withdrawal_Account] { + var sources = Buffer.Buffer(0); + let ids = Option.get>(withdrawalsByUser_account.get(account), Buffer.Buffer(0)); + for (id in ids.vals()) { + switch (withdrawals_account.get(id)) { + case (null) { P.unreachable(); }; + case (?w) { + sources.add(w); + }; + }; + }; + return sources.toArray(); + }; + + public func completeWithdrawal_account(user: Account, amount: Nat64, to: NNS.AccountIdentifier): Result.Result { + let now = Time.now(); + let subaccount = switch (user.subaccount){ + case null {nullBlob}; + case (? subaccount ) {subaccount}; + }; + + // Figure out which available withdrawals we're disbursing + var remaining : Nat64 = amount; + var b = Buffer.Buffer<(Withdrawal_Account, Nat64)>(1); + for (w in withdrawalsFor_account(user).vals()) { + if (remaining > 0 and w.available > 0) { + let applied = Nat64.min(w.available, remaining); + b.add((w, applied)); + remaining -= applied; + }; + }; + // Check the user has enough available + if (remaining > 0) { + return #err(#InsufficientBalance); + }; + + // Update these withdrawal balances. + for ((w, applied) in b.vals()) { + let disbursedAt = if (w.disbursed + applied == w.total) { + ?now + } else { + null + }; + withdrawals_account.put(w.id, { + id = w.id; + account = w.account; + createdAt = w.createdAt; + expectedAt = w.expectedAt; + readyAt = w.readyAt; + disbursedAt = disbursedAt; + total = w.total; + pending = w.pending; + available = w.available - applied; + disbursed = w.disbursed + applied; + }); + }; + + #ok({ + transferArgs = { + memo : Nat64 = 0; + from_subaccount = ?Blob.toArray(subaccount); + to = Blob.toArray(to); + amount = { e8s = amount - icpFee }; + fee = { e8s = icpFee }; + created_at_time = ?{ timestamp_nanos = Nat64.fromNat(Int.abs(now)) }; + }; + failure = func() { + // To be called if the transfer has failed. + + // Revert our changes, returning the withdrawal balance to + // the user. + for (({id}, applied) in b.vals()) { + let w = switch (withdrawals_account.get(id)) { + case (null) { P.unreachable() }; + case (?w) { w }; + }; + withdrawals_account.put(w.id, { + id = w.id; + account = w.account; + createdAt = w.createdAt; + expectedAt = w.expectedAt; + readyAt = w.readyAt; + disbursedAt = null; + total = w.total; + pending = w.pending; + available = w.available + applied; + disbursed = w.disbursed - applied; + }); + }; + }; + }) + }; + + + //////////////////////////////////////////////////////////////////// + private func nextWithdrawalId(user: Principal): Text { let count = Option.get>( withdrawalsByUser.get(user), diff --git a/src/deposits/deposits.mo b/src/deposits/deposits.mo index 3660d4b..759d0ce 100644 --- a/src/deposits/deposits.mo +++ b/src/deposits/deposits.mo @@ -20,6 +20,7 @@ import Result "mo:base/Result"; import Text "mo:base/Text"; import Time "mo:base/Time"; import TrieMap "mo:base/TrieMap"; +import Nat8 "mo:base/Nat8"; import Daily "./Daily"; import ApplyInterest "./Daily/ApplyInterest"; @@ -426,6 +427,37 @@ shared(init_msg) actor class Deposits(args: { NNS.accountIdToText(NNS.accountIdFromPrincipal(Principal.fromActor(this), NNS.principalToSubaccount(msg.caller))); }; + //////////////////////////////// for subaccount functions ///////////////////////////// + + private func accountToSubaccount(account: Account.Account): Blob { + let ownerToSubaccount = NNS.principalToSubaccount(account.owner); + switch(account.subaccount) { + case (null) { return ownerToSubaccount}; + case (?subaccount) { + let a1 = Blob.toArray(ownerToSubaccount); + let a2 = Blob.toArray(subaccount); + let combined = Array.tabulate( + 32, + func(i) { + Nat8.fromNat((Nat8.toNat(a1[i]) + Nat8.toNat(a2[i])) % 256) + } + ); + Blob.fromArray(combined) + }; + } + }; + + public shared query(msg) func getDepositAddressForSubaccount(subaccount: ?Blob): async Text { + let userSubaccount = accountToSubaccount({ owner = msg.caller; subaccount}); + NNS.accountIdToText(NNS.accountIdFromPrincipal(Principal.fromActor(this), userSubaccount)); + }; + + public shared ({ caller }) func depositIcpForSubaccount(subaccount: Blob): async DepositReceipt{ + await doDepositIcpFor(caller, ?subaccount); + }; + + //////////////////////////////////////////////////////////////////////////////////// + // Same as getDepositAddress, but allows the canister owner to find it for // a specific user. public shared query(msg) func getDepositAddressFor(user: Principal): async Text { @@ -465,19 +497,20 @@ shared(init_msg) actor class Deposits(args: { // After the user transfers their ICP to their depositAddress, process the // deposit, be minting the tokens. public shared(msg) func depositIcp(): async DepositReceipt { - await doDepositIcpFor(msg.caller); + await doDepositIcpFor(msg.caller, null); }; // After the user transfers their ICP to their depositAddress, process the // deposit, be minting the tokens. public shared(msg) func depositIcpFor(user: Principal): async DepositReceipt { owners.require(msg.caller); - await doDepositIcpFor(user) + await doDepositIcpFor(user, null) }; - private func doDepositIcpFor(user: Principal): async DepositReceipt { + private func doDepositIcpFor(user: Principal, _subaccount: ?Blob): async DepositReceipt { // Calculate target subaccount - let subaccount = NNS.principalToSubaccount(user); + // let subaccount = NNS.principalToSubaccount(user); + let subaccount = accountToSubaccount({ owner = user; subaccount = _subaccount }); let sourceAccount = NNS.accountIdFromPrincipal(Principal.fromActor(this), subaccount); // Check ledger for value @@ -534,7 +567,7 @@ shared(init_msg) actor class Deposits(args: { // Mint the new tokens Debug.print("[Referrals.convert] user: " # debug_show(user)); referralTracker.convert(user, ?now); - let userAccount = {owner=user; subaccount=null}; + let userAccount = {owner=user; subaccount= ?subaccount}; ignore queueMint(userAccount, toMintE8s); ignore flushMint(userAccount); @@ -814,7 +847,14 @@ shared(init_msg) actor class Deposits(args: { return #err(#InsufficientLiquidity); }; - return #ok(withdrawals.createWithdrawal(user.owner, toUnlockE8s, delay)); + // return #ok(withdrawals.createWithdrawal(user.owner, toUnlockE8s, delay)); + return #ok( + if(user.subaccount == null){ + withdrawals.createWithdrawal(user.owner, toUnlockE8s, delay) + } else { + {withdrawals.createWithdrawal_account(user, toUnlockE8s, delay) with user = msg.caller} + } + ) }; // Complete withdrawal(s), transferring the ready amount to the @@ -896,6 +936,95 @@ shared(init_msg) actor class Deposits(args: { result }; + ///////////////// Complete Withdrawal for account /////////////////////////// + + type Account = {owner: Principal; subaccount: ?Blob}; + + public shared(msg) func completeWithdrawal_account(account: Account, amount: Nat64, to: Text): async Withdrawals.PayoutResult { + let user = account.owner; + let subaccount = switch (account.subaccount) { + case null {NNS.defaultSubaccount()}; + case (?subaccount) {subaccount}; + }; + + if (msg.caller != user) { + owners.require(msg.caller); + }; + + // See if we got a valid address to send to. + // + // Try to parse text as an address or a principal. If a principal, return + // the default subaccount address for that principal. + let toAddress = switch (NNS.accountIdFromText(to)) { + case (#err(_)) { + // Try to parse as a principal + try { + NNS.accountIdFromPrincipal(Principal.fromText(to), subaccount) + } catch (error) { + return #err(#InvalidAddress); + }; + }; + case (#ok(toAddress)) { + if (NNS.validateAccountIdentifier(toAddress)) { + toAddress + } else { + return #err(#InvalidAddress); + } + }; + }; + + // Check we think we have enough cash available to fulfill this. + // We can't use _availableBalance here, because it subtracts out the + // withdrawals.reservedICP, which is what we actually want to use for + // this fulfillment. + if (Nat64.min(cachedLedgerBalanceE8s, withdrawals.reservedIcp()) < amount + pendingTransfers.reservedIcp()) { + return #err(#InsufficientLiquidity); + }; + + + // Mark withdrawals as complete, and the balances as "disbursed" + let {transferArgs; failure} = switch (withdrawals.completeWithdrawal(user, amount, toAddress)) { + case (#err(err)) { return #err(err); }; + case (#ok(a)) { a }; + }; + + // Mark the funds as unavailable while the transfer is pending. + let transferId = pendingTransfers.add(amount); + + // Attempt the transfer, reverting if it fails. + let result = try { + let transfer = await ledger.transfer(transferArgs); + switch (transfer) { + case (#Ok(block)) { + pendingTransfers.success(transferId); + #ok(block) + }; + case (#Err(#InsufficientFunds{})) { + // Not enough ICP in the contract + pendingTransfers.failure(transferId); + failure(); + #err(#InsufficientLiquidity) + }; + case (#Err(err)) { + pendingTransfers.failure(transferId); + failure(); + #err(#TransferError(err)) + }; + } + } catch (error) { + pendingTransfers.failure(transferId); + failure(); + #err(#Other(Error.message(error))) + }; + + // Queue a balance refresh. + ignore refreshAvailableBalance(); + result + }; + + + ///////////////////////////////////////////////////////////////////////////// + // List all withdrawals for a user. // public shared(msg) func listWithdrawals(user: Principal) : async [Withdrawals.Withdrawal] { public func listWithdrawals(user: Principal) : async [Withdrawals.Withdrawal] {