Minting NFTs based on ICP transactions, entirely onchain.
The goal is to allow an individual to send ICP to us, and based on the completion of that transaction we will mint an NFT to their principal. Additionally, we may wish to refund that transfer under certain conditions, such as when there are no more NFTs left to mint. To achieve this, we need access to onchain transaction data.
There are some caveats to the approach that we will be using, which I will mention from the outset. First, completing the transaction will require the user to make two calls to the ledger canister. We can simplify this within our frontend application by performing these calls in an automatic sequence, but if a user is making these calls another way they will have to take this into account. Second, there's nothing stopping a user from sending us a transaction from a principal that does not support NFTs, which could result in an NFT being minted into a "blackhole." Recovery from such an event should not be impossible, however, and we can explore that recovery later on.
For the first milestone in this exercise, we will create a canister which authenticates the completion of a transaction from a user's principal to our destination principal. For now the only other functionality that we will provide is a way to list out those authenticated transactions. In the future we can extend this to add minting an NFT based on an authenticated transaction. We will also make calls to the ledger canister directly for now, instead of building out a frontend to complete the transaction.
We rely on two methods on the Internet Computer's official ledger canister: send_dfx
and notify_dfx
. It should be noted that relying on these methods is explicitly discouraged in the source code. They are apparently likely to break in the future, and the protobuff versions of these same methods are to be preferred. Perhaps there is a Motoko protobuff implementation which would allow us to proceed on that recommended path, but for the time being we will rely on these methods.
- TODO: Assess eventuality of backwards incompatible changes to
send_dfx
andnotify_dfx
.
As well as the official ICP Ledger Canister, we will also be developing our own Transaction Authenticator Canister (which we will call TxAuth,) and we will need a wallet which will receive the funds of each transaction.
The logical flow on the blockchain is fairly straightforward.
- User calls
send_dfx(memo, amount, fee, from_subaccount, to, created_at_time)
on Ledger to send ICP to our receiving canister (we'll break down the parameters of that function call shortly.) - User calls
notify_dfx(block_height, max_fee, from_subaccount, to_canister, to_subaccount)
. - TxAuth receives the notification from Ledger that was created by our
notify_dfx
call. We validate the data we are receiving from the Ledger, and add it to our list of authenticated transactions.
Seems easy enough, right? Let's start building!!
First, let's try just sending some ICP between principals using the send_dfx
method. Thanks to Moritz, we can read this helpful article all about interacting with the IC via CLI.
Here's the bit we're interested in:
❯ dfx canister --network ic --no-wallet call ryjl3-tyaaa-aaaaa-aaaba-cai send_dfx \
'(
record {
memo = 1 : nat64;
amount = record {e8s = <AMOUNT_TO_SEND> : nat64};
fee = record {e8s = 10_000 : nat64};
to = "<DESTINATION_ADDRESS>"
}
)'
The to
field should be in the address format, not the principal format. For our amount, we can simply send 1. We will now use dfx to send ICP from one of our accounts to another using this method. You can manage these identities using the dfx identity
, which I won't cover. We are not so concerned with the memo parameter for the time being, but it is required, so we'll just give it any old value for now.
❯ dfx canister --network ic --no-wallet call ryjl3-tyaaa-aaaaa-aaaba-cai send_dfx \
'(
record {
memo = 1 : nat64;
amount = record {e8s = 1 : nat64};
fee = record {e8s = 10_000 : nat64};
to = "<IDENTITY_#2_ADDRESS>";
}
)'
(1_262_859 : nat64)
The response that we received is the blockheight where our transaction was completed (1_262_859
). Take note, because we'll need that to trigger our notification.
If we now switch to our second identity, we can see our updated balance!
❯ dfx ledger --network ic balance
0.00000001 ICP
The next step would be to trigger a notification, but we can't really do that until our TxAuth Canister is up and ready to receive it! However, we can look at what that call would be in dfx:
❯ dfx canister --network ic --no-wallet call ryjl3-tyaaa-aaaaa-aaaba-cai notify_dfx \
'(
record {
block_height = 1_262_859 : nat64;
max_fee = record {e8s = 10_000 : nat64};
to_canister = principal "CANISTER-ID-IN-PRINCIPAL-FORMAT";
}
)'
If we run this now, we will see the following error: Notification failed with message 'Canister <...> does not exist'
. So, let's get started on our TxAuth canister!
Because we are going to be relying on the real IC ledger canister (ryjl3-tyaaa-aaaaa-aaaba-cai
), we'll build directly on Mainnet.
We can see in the source code that Ledger's notify_dfx
method will call transaction_notification
on our TxAuth Can. All we want to do is capture the message from the Ledger notification, and push a record into our list of authenticated transactions.
The first thing we need to do is determine the type of data that we will be receiving from the Ledger and storing in our TxAuth Canister. We can pull this from source. The rest of the types we can pull from the candid interface.
type CanisterId = Principal;
type BlockHeight = Nat64;
type Memo = Nat64;
type SubAccount = [Nat8];
type ICPTs = {
e8s : Nat64;
};
type TransactionNotification = {
from: Principal;
from_subaccount: ?SubAccount;
to: CanisterId;
to_subaccount: ?SubAccount;
block_height: BlockHeight;
amount: ICPTs;
memo: Memo;
};
Next, we'll need to define some state to capture transactions and persist them between canister upgrades.
var stableTransactions : [TransactionNotification] = [];
let transactions = Buffer.Buffer<TransactionNotification>(stableTransactions.size());
// Provision transactions from stable memory
for (v in stableTransactions.vals()) {
transactions.add(v);
};
system func preupgrade () {
// Preserve transactions before upgrades
stableTransactions := transactions.toArray();
};
Next up, we'll create the method to receive notifications from the Ledger.
public shared ({ caller }) func transaction_notification (args : TransactionNotification) : async () {
// We need to make sure that only the Ledger can call this endpoint
let ledger = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai");
assert(caller == ledger);
transactions.add(args);
};
The last thing we need is a method to read the state of our authenticated transactions.
public query func readTransactions () : async ([TransactionNotification]) {
transactions.toArray();
}
See main.mo for complete canister code.
With that in place, we can deploy our TxAuth canister to Mainnet.
❯ dfx deploy --network ic
And now we can use notify_dfx
to send the transaction from the Ledger to our new TxAuth canister!
Unfortunately, there appears to be a limitation on only the recipient of a transaction being able to be notified of that transaction: https://github.com/dfinity/ic/blob/c58c75a687621530b2635b22630e9562424fa3b3/rs/rosetta-api/ledger_canister/src/main.rs#L246. I must be missing something 🤔.
Still, if I send ICP to my canister principal, I can notify it and read out the authenticated transaction:
❯ dfx canister --network ic --no-wallet call ryjl3-tyaaa-aaaaa-aaaba-cai send_dfx \
'(
record {
memo = 1 : nat64;
amount = record {e8s = 1 : nat64};
fee = record {e8s = 10_000 : nat64};
to = "fecf37d8f227ad6bd02f259794c3414080fd6f4ac2a9ef49ccb3dea1bd3ad01a"
}
)'
(1_263_850 : nat64)
❯ dfx canister --network ic --no-wallet call ryjl3-tyaaa-aaaaa-aaaba-cai notify_dfx \
'(
record {
block_height = 1_263_850 : nat64;
max_fee = record {e8s = 10_000 : nat64};
to_canister = principal "lykvf-5qaaa-aaaaj-qaimq-cai";
}
)'
()
❯ dfx canister --network ic call minter readTransactions
(
vec {
record {
to = principal "lykvf-5qaaa-aaaaj-qaimq-cai";
to_subaccount = null;
from = principal "k2syn-nenrg-67lse-cn2pm-srhsr-c3rsj-tfatg-63ga5-pz25g-x56ob-2ae";
memo = 1 : nat64;
from_subaccount = null;
amount = record { e8s = 1 : nat64 };
block_height = 1_263_850 : nat64;
};
},
)