DeVeFi Ledger - ICRC ledger middleware

Very excited to share this library (changed to middleware later) with you.

We extracted it from our latest project after noticing some mechanisms inside can be reused in a lot of the DeFi canisters - not only DeFi Vectors.

It allows devs to use the library synchronously while it handles asynchronicity with icrc ledgers.

Example usage:

import L "mo:devefi-icrc-ledger";
import Principal "mo:base/Principal";


actor class() = this {

    stable let lmem = L.LMem(); 
    let ledger = L.Ledger(lmem, "mxzaz-hqaaa-aaaar-qaada-cai");
    public func start() : async () { ledger.start(this) };
    ledger.onReceive(func (t) = ignore ledger.send({ to = t.from; amount = t.amount; from_subaccount = t.to.subaccount; }));

}

Previously hard to achieve:

Getting notified automatically when there is a transfer to your canister (any subaccount)

ledger.onReceive(func (t : Ledger.Transfer) {
 // handle incoming transfers to your canister
});

To do that it follows ledger history using timers.

Sending tokens with certainty without worrying about async calls

ignore ledger.send({ 
  to = {owner; subaccount};
  amount = t.amount; 
  from_subaccount = t.to.subaccount; 
}) // returns #ok(local_tx_id) or #err(#insufficient_funds)

This basically adds the transaction to a BTree queue and removes the amount from the local account’s (in_transit) balance. Local accounts are a Hashmap with {balance: Nat, in_transit: Nat}. The library won’t allow you to use the amount that is in transit.
When transfers come and go to your canister they automatically update the balance and in_transit. When the transfer gets confirmed after finding the block inside the ledger log, the amount gets reduced from in_transit

Get the balance of canister accounts synchronously

ledger.balance(subaccount) : Nat

This basically returns balance - in_transit. It keeps only accounts owned by the canister.

A bit more about how sending works
It’s done with oneway calls and doesn’t wait for a callback.
Transactions are retried indefinitely every X minutes until they arrive and retrying relies on deduplication.
Once a transaction is sent, there is no need for the developer to handle confirmations manually. They should consider it done. The transaction will be retried forever. The funds for that transaction are locked and inaccessible through the library. If the canister is DAO governed or black-holed and audited, I believe that will provide near atomic transaction security.
You could queue up to thousands of messages - as much as you can add inside a BTree in one call.

If you want to use 30 different ledgers inside one canister, it’s probably better if someone makes a ledger notification service that brings the info to this library.

https://mops.one/devefi-icrc-ledger
It uses https://mops.one/devefi-icrc-sender and https://mops.one/devefi-icrc-reader

Disclaimer! This library is not audited and not ready for production use. It may change its interface a lot. Currently, it’s a proof of concept. There is a lot of room for improvement and if someone wants to help with it let us know.

4 Likes

Relevant from Scalable Messaging Model - #6 by skilesare :

2 Likes

Any idea how we can tell the library (inside the body) what is the actor’s principal in Motoko?

system func postupgrade() { ledger.start(this) }; // starts timers as well

This works but doesn’t trigger on install, only after an upgrade.
Also trying to start more than 3-4 timers inside the body results in some not registering.

1 Like

I think you need to set a timer inside the main canister initialization with a time of zero, so it runs right after initialization. You’ll have access to this inside of that timer function.

I thought so too, but it’s not working

What if you put it in its own top level async function and then just reference the name function?

1 Like

As a workaround, instead of computing the principal once and storing it, you can re-compute the actor’s principal from this every time you need it.

2 Likes

Do you mean something like this?

@timo Yes, that works when you have actors with public functions, which covered 100% of the cases until now. But in our case, we better not add another public function just to get things started.

It’s not that big of a problem tho, but will make the library harder to use if there is a special sequence one has to do to get it working. Like calling a function, passing canister_id through init params, or upgrading right after installing. … Oh it can actually call a blackholed canister with whoami :rocket:

ledger.start(this)

probably @timo meant something like this:

ledger.start(Principal.fromActor(this));

we’re using such approach and have no issues

I was thinking ledger.start(this) could be put in top-level actor code if we didn’t call Principal.fromActor() right away on the argument. But I was wrong, it doesn’t work. So please disregard my comment.

New versions for all 3 libraries.

Test scripts https://github.com/Neutrinomic/devefi_icrc_ledger/tree/master/test

Test results

Throughput per ledger
Sending - to lib queue - unlimited
Sending - from queue to ledger: 91tx/s
Read - from ledger - 250tx/s

Test methodology - We ran a locally deployed ledger canister (the last one from SNSW)
The test receives a large amount of tokens and splits that by sending 10,000 transactions to different accounts. These accounts resend the transactions to other accounts until the amount < fee. This way we could just send enough tokens for 20,000 transactions and check out how many were made at the end of the test.
We were stopping and starting both the test canister with this library and the ledger multiple times. We also upgraded the test canister a few times in the middle of testing.
Numerous trials were conducted, and not a single transaction was missed , whether during the sending or receiving of events.
We also made the replica throw errors when sending and that didn’t cause problems for the queue.

Notice: This test was done with Dfinity’s icrc ledger (ICP ledger excluded, it’s tx log is different). Other ledgers may not work. This library relies on deduplication and get_transactions soon to be replaced with ICRC-3. Both have to work impeccably.

https://mops.one/devefi-icrc-ledger
Version 1.1.1 is out. More tests have been added, and previously identified bugs within the balance function have now been fixed. Documentation in mops added. It’s ready to get audited now.

Methodology: Execute 20,000 transactions (the ledger is configured to split the archive after every 10,000 transactions). Obtain a hash from all account balances owned by the canister. Reinstall the canister and start from block 0 (removing hooks). Generate a second hash and compare it to the first; the two hashes should match. Additionally, retrieve all accounts using the new accounts function and directly check their balances by calling the ledger. Compare both sets of balances to ensure they match. The library has passed this test multiple times.

Version 1.1.2 is out. After testing our NTN airdrop script in production (mainnet) we noticed the sending throughput was lower, so we reduced it to a max of 45tx/s also when an error occurs “could not perform oneway” it stops sending anything else during the current cycle (every ~2sec), and resumes the next one.

2 Likes