|
| 1 | +import Debug "mo:core/Debug"; |
| 2 | +import Map "mo:core/Map"; |
| 3 | +import Nat "mo:core/Nat"; |
| 4 | +import Nat8 "mo:core/Nat8"; |
| 5 | +import Nat64 "mo:core/Nat64"; |
| 6 | +import Principal "mo:core/Principal"; |
| 7 | +import Runtime "mo:core/Runtime"; |
| 8 | +import Text "mo:core/Text"; |
| 9 | +import Time "mo:core/Time"; |
| 10 | + |
| 11 | +import Types "app.types"; |
| 12 | + |
| 13 | +// This actor: |
| 14 | +// - stores merchant information, |
| 15 | +// - monitors the ICRC-1 ledger for incoming transfers, and |
| 16 | +// - logs where a merchant notification would be sent. |
| 17 | +// |
| 18 | +// The ledger canister is resolved at runtime from the injected |
| 19 | +// `PUBLIC_CANISTER_ID:icrc1_ledger` environment variable: |
| 20 | +// local: the pre-built ICRC-1 ledger deployed by deploy.sh |
| 21 | +// ic: the TICRC1 test ledger (see icp.yaml) |
| 22 | +// |
| 23 | +// `_startBlock` is the ledger block index to start monitoring from. |
| 24 | +// |
| 25 | +// NOTE: notifications are illustrative. The original example sent email/SMS via |
| 26 | +// an HTTPS outcall to a third party; this version only logs that a notification |
| 27 | +// could be sent. To implement real notifications, use HTTPS outcalls — see |
| 28 | +// https://docs.internetcomputer.org/guides/backends/https-outcalls |
| 29 | +// |
| 30 | +// NOTE: scanning the ledger's global transaction log sequentially does not scale |
| 31 | +// to a busy shared ledger (such as TICRC1); it is illustrative only. A |
| 32 | +// production app would query the index canister per merchant account instead |
| 33 | +// (as this example's frontend already does). |
| 34 | +actor class Main(_startBlock : Nat) { |
| 35 | + |
| 36 | + // Minimal subset of the ICRC-1 ledger interface this actor uses. Candid |
| 37 | + // ignores wire fields not declared here, so only the read fields are listed. |
| 38 | + type Account = { owner : Principal }; |
| 39 | + type Transfer = { to : Account; from : Account; amount : Nat }; |
| 40 | + type Transaction = { kind : Text; transfer : ?Transfer }; |
| 41 | + type GetTransactionsRequest = { start : Nat; length : Nat }; |
| 42 | + type GetTransactionsResponse = { transactions : [Transaction] }; |
| 43 | + type Ledger = actor { |
| 44 | + get_transactions : GetTransactionsRequest -> async GetTransactionsResponse; |
| 45 | + icrc1_symbol : () -> async Text; |
| 46 | + icrc1_decimals : () -> async Nat8; |
| 47 | + }; |
| 48 | + |
| 49 | + let merchantStore = Map.empty<Text, Types.Merchant>(); |
| 50 | + // Next ledger block index to scan for incoming transfers. |
| 51 | + var nextBlock : Nat = _startBlock; |
| 52 | + // Token metadata, cached lazily from the ledger to format logged amounts. |
| 53 | + // Transient: re-fetched on demand after an upgrade. |
| 54 | + transient var tokenSymbol : Text = "tokens"; |
| 55 | + transient var tokenDecimals : Nat = 8; |
| 56 | + transient var metadataLoaded : Bool = false; |
| 57 | + |
| 58 | + // How many ledger blocks to scan per timer tick. Draining a batch (rather than |
| 59 | + // one block per tick) means a payment is logged within a single tick even when |
| 60 | + // the monitor is catching up on a backlog (e.g. the initial mint). |
| 61 | + let scanBatchSize : Nat = 100; |
| 62 | + |
| 63 | + // Get the caller's merchant information. |
| 64 | + public query (context) func getMerchant() : async Types.Response<Types.Merchant> { |
| 65 | + switch (merchantStore.get(context.caller.toText())) { |
| 66 | + case (?merchant) { |
| 67 | + { status = 200; status_text = "OK"; data = ?merchant; error_text = null }; |
| 68 | + }; |
| 69 | + case null { |
| 70 | + { |
| 71 | + status = 404; |
| 72 | + status_text = "Not Found"; |
| 73 | + data = null; |
| 74 | + error_text = ?("Merchant with principal ID: " # context.caller.toText() # " not found."); |
| 75 | + }; |
| 76 | + }; |
| 77 | + }; |
| 78 | + }; |
| 79 | + |
| 80 | + // Create or update the caller's merchant information. |
| 81 | + public shared (context) func updateMerchant(merchant : Types.Merchant) : async Types.Response<Types.Merchant> { |
| 82 | + merchantStore.add(context.caller.toText(), merchant); |
| 83 | + { status = 200; status_text = "OK"; data = ?merchant; error_text = null }; |
| 84 | + }; |
| 85 | + |
| 86 | + // Resolve the ledger canister, injected as PUBLIC_CANISTER_ID:icrc1_ledger. |
| 87 | + func ledger<system>() : Ledger { |
| 88 | + switch (Runtime.envVar<system>("PUBLIC_CANISTER_ID:icrc1_ledger")) { |
| 89 | + case (?id) actor (id) : Ledger; |
| 90 | + case null Runtime.trap("PUBLIC_CANISTER_ID:icrc1_ledger not set — run icp deploy"); |
| 91 | + }; |
| 92 | + }; |
| 93 | + |
| 94 | + // Fetch the token's symbol and decimals from the ledger once, then cache them. |
| 95 | + func loadMetadataOnce<system>() : async () { |
| 96 | + if (metadataLoaded) return; |
| 97 | + let l = ledger<system>(); |
| 98 | + tokenSymbol := await l.icrc1_symbol(); |
| 99 | + tokenDecimals := Nat8.toNat(await l.icrc1_decimals()); |
| 100 | + metadataLoaded := true; |
| 101 | + }; |
| 102 | + |
| 103 | + // Render a base-unit amount using the cached decimals and symbol, e.g. with |
| 104 | + // 8 decimals: 100_000_000 -> "1 LICRC1", 50_000 -> "0.0005 LICRC1". |
| 105 | + func formatAmount(amount : Nat) : Text { |
| 106 | + let base = 10 ** tokenDecimals; |
| 107 | + let whole = (amount / base).toText(); |
| 108 | + let frac = amount % base; |
| 109 | + if (frac == 0) return whole # " " # tokenSymbol; |
| 110 | + // Left-pad the fractional digits to `tokenDecimals`, then drop trailing zeros. |
| 111 | + var fracText = frac.toText(); |
| 112 | + while (fracText.size() < tokenDecimals) { fracText := "0" # fracText }; |
| 113 | + whole # "." # Text.trimEnd(fracText, #char '0') # " " # tokenSymbol; |
| 114 | + }; |
| 115 | + |
| 116 | + // Scan for new transactions and log a would-be notification for each payment |
| 117 | + // to a merchant that has notifications enabled. Called by the global timer. |
| 118 | + system func timer(setGlobalTimer : Nat64 -> ()) : async () { |
| 119 | + let next = Nat64.fromIntWrap(Time.now()) + 20_000_000_000; // 20 seconds |
| 120 | + setGlobalTimer(next); |
| 121 | + await notify<system>(); |
| 122 | + }; |
| 123 | + |
| 124 | + func notify<system>() : async () { |
| 125 | + let response = await ledger<system>().get_transactions({ start = nextBlock; length = scanBatchSize }); |
| 126 | + let txs = response.transactions; |
| 127 | + if (txs.size() == 0) return; // caught up; check again next tick |
| 128 | + nextBlock += txs.size(); |
| 129 | + |
| 130 | + var i = 0; |
| 131 | + label scan while (i < txs.size()) { |
| 132 | + let t = txs[i]; |
| 133 | + i += 1; |
| 134 | + if (t.kind != "transfer") continue scan; |
| 135 | + switch (t.transfer) { |
| 136 | + case (?transfer) { |
| 137 | + switch (merchantStore.get(transfer.to.owner.toText())) { |
| 138 | + case (?merchant) { |
| 139 | + // A payment for a merchant with notifications enabled: emit a |
| 140 | + // canister log (observable via `icp canister logs backend`) marking |
| 141 | + // where a real notification would be sent. To actually send one, |
| 142 | + // use HTTPS outcalls — |
| 143 | + // https://docs.internetcomputer.org/guides/backends/https-outcalls |
| 144 | + if (merchant.email_notifications or merchant.phone_notifications) { |
| 145 | + if (not metadataLoaded) await loadMetadataOnce<system>(); |
| 146 | + Debug.print( |
| 147 | + "Payment of " # formatAmount(transfer.amount) # " received by merchant '" # merchant.name # |
| 148 | + "' from " # transfer.from.owner.toText() # |
| 149 | + ". A notification could be sent here via HTTPS outcalls." |
| 150 | + ); |
| 151 | + }; |
| 152 | + }; |
| 153 | + case null {}; |
| 154 | + }; |
| 155 | + }; |
| 156 | + case null {}; |
| 157 | + }; |
| 158 | + }; |
| 159 | + }; |
| 160 | +}; |
0 commit comments