-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ICRC-84 [WIP]: Deposit and Withdrawal Standard for ICRC-1 tokens #84
Comments
Very happy to see this as I've almost written it a couple dozen times. Lots of great thoughts. A few thoughts:
Why? For ICRC1/2 tokens the id will always be null, but if there are future multi-token canisters(much like you designed for HPL). Adding this now will make it easier to use in the future without refactoring or having to issue a new ICRC.
|
@skilesare Thanks for reviewing this and thanks for your interesting comments. I would love to discuss them further. Re 1. Any future multi-token ledger, HPL or other, will not comply with ICRC-1 because the ICRC-1 itself does not allow to specify an asset id or similar in its interface. So we have to decide if this standard (icrc-84) is supposed to be icrc-1 specific or if its more general. I thought for a while to make it more general and at that time wanted to specify the token in the form of a variant like At the same time, it is not wise to forego easy ways that can buy us extendability. So for the token specification we would either make it a variant (like |
Re. 2 for As for the order, what exactly is the requirement? Is it only that the order is deterministic (as in for example alphabetical order)? Or is it append-only so that newly added tokens do not change the order of older tokens (as in for example chronological order, the order in which tokens are added to the service)? I would expect that for pagination to be useful for the caller the order has to be the chronological one. I am wondering what requirements we create for the implementation, hence the question. For example, if the supported tokens are stored in a tree then the type of tree has to be chosen so that the order matches the iteration order when iterating through the tree entries. Or there need to be two data structures, a tree for lookup and a vector for enumeration. It is not necessarily a problem, we just have to be aware of it when we add pagination. And we have to be aware that we may be exposing an implementation detail. For example a token principal may now have an "index" in the sequence which also uniquely identifies it (in this service at least). |
Re. 2 for
There is also a different approach possible which is an alternative approach to pagination. It is if the caller provides, in an argument, the list of tokens that he wants to query. Then the caller can make sure the list isn't too long and can split a long list over two calls. It is also more flexible if the user interested in an update of just a few balances, not all. However, it cannot be used if the caller has forgotten which tokens he owns and wants to know all balances that he owns. So there is a trade-off. |
Re. 3 (notify/lock). Are you saying that any implementation will necessarily require locks? (probably true, just asking) |
Re 4. What do you mean by ICRC-10 key? I see the ICRC-10 record has a |
Re. 5. Can you elaborate more please? ICRC-3 is for "events" not for balances, right? What events are secret here that you would like to see ICRC-3 block types for? |
Re 1.
I think this might make the most sense, but using a variant might be more complete. I'd be open to either. Apparently, the new motoko compiler will do way with having to null the Variants to make them backwards and forwards compatible, so that might be the easiest. I'd suggest Not to confuse too many issues, but if we did actually do #icrc1 and #icrc2 we could specifically reject icrc2 notify attempts and/or icrc1 deposit attempts. I guess those will fail gracefully anyway, but pushing people down the transfer from route probably makes the code cleaner and avoids locks if it is available.(Just thinking out loud here....I think just having #icrc1 is fine for now. |
Re. 2
This pops up a ton when discussing these standards and I've had to go back and retrofit a few indexes into some things that I didn't really want to do, but we probably want consistent, deterministic ordering that makes sense to both the publisher and consumer. Supported tokens seems like it would be either alpha by ledger canister id or binary order by canister ID....the Token objects we have don't have a symbol saved in the data, or I would say that makes the most sense. Likely not a huge deal here at the moment....I think it may be safe to says MUST return items in a "consistent order" and leave the exact order up to the implementation. Re. 2 and I think you just want to return items with a non-0 credit balance, and again, using a "consistent order". You just want to make sure that the consumer is able to get all the items by going from front to back. Obviously it is possible for something to get inserted up at the front of the queue while querying if there are bunch of pages, but I don't think there is much that we can do about that. |
Re. 3 Locks are probably the answer for this if you don't want to do burned blocks. Basically, my brain has this block that occurs when I start thinking about all the ways the IC could fail and squaring it with all the tech there to make sure that doesn't happen(and that is before I get stressed out about my own bugs). Of course, a canister can be in the middle of a upgrade, or a subnet could be really slow, or the output queue could get full and time out after five minutes or something. I was doing an implementation of withdraw and I avoided a lock by burning the balance before trying to send out the tokens and then minting them back if something fails. I think this works well and avoids a double withdraw. Notify is a bit more problematic as you don't want rapid requests getting into a race condition. Probably better to just put some kind of lock on that account until you get some response back and have a clean up timer that runs once a day just in case something odd happens. Again...just thinking out loud here. Ideally I'd like to just not have notify and force everyone through approvals and in the contract I'm building now it always uses ICP so I know that is there. |
I'm just saying that the standard should specifically say that an ICRC-10 record indicating support for icrc-84 should be added so that wallets and other services can detect that it is supported. Basically just copying this section and changing it to icrc84 https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-7/ICRC-7.md#icrc10_supported_standards |
I'm just posing the question that if an ICRC-84 service doesn't have any reason to keep credit balances a secret and wants to publish a complete transaction log of all activity on the server, it would be nice to have the ICRC-3 block schemas defined somewhere. For example, for ICRC-72 we pulled the block schemas out and moved them to ICRC-83 as there are likely many event systems that won't want to log every state change to the service, but for those that do, we provide the schemas: https://github.com/icdevs/ICEventsWG/blob/main/Meetings/20240529/icrc83draft.md (this is still an early draft) |
A few implementation thoughts: An ICRC-84 service does not have subaccount. This means that if a canister wants to use it, it will need to co-mingle user funds in the same set of credits without a way to distinguish who's credits are who's on a per token basis. Use case: I have written a bot that monitors prices across the IC and I offer a menu of possible actions that users can configure their actions to draw from(see defi vectors https://github.com/Neutrinomic/defivectors). In this case, the vector canister would need to keep all funds in a default account and track them locally. This may be by design, but I thought it was worth stating out loud. I was trying to implement it for use in a simple 'wrapped token' contract that tracks cycles for a tokenomics use case and then lets users withdraw them to the new cycles ledger. I didn't need deposits or notify because the balances are pushed in from the behavior of other users. Once the balances were there though, I had them going to standard ICRC1 type accounts.....this meant that if _credits and _trackedDeposit only work for the default account. Not a huge deal, but maybe there is a solution for this? Derived canister IDs would help for this if there were services that wanted to use this kind of system from one canister while managing many user's accounts. For withdrawal, I have the workaround that it will withdraw from the same icrc1 account as specified in the to field, but this obviously restricts some usefulness as you can't direct your funds to a different account. I have a bit of an alternative take on this kind of multi token deposit system that I've been working on and it is now about third down on my todo list...I wanted to take a quick turn at implementation before I released much about it, but perhaps we can compare notes. It has the same kind of restriction that you lose multi-subaccount functionality |
It is not that easy. Just changing the
A different ledger (take for example HPL) may not have subaccounts but may need some other fields to specify a pull transfer. The same holds for the argument of the We would have to wrap the whole argument list into a variant with case |
Re. pagination for Without such a mechanism the user could accidentally miss some supported tokens. |
Approvals require the user to make this additional step of setting up an allowance. It's an additional friction for someone who quickly wants to try out the service for the first time. He cannot deposit from an exchange. And most wallets don't support setting allowances yet, or do they? Moreover, you need to properly use an allowance with a subaccount built into it (not just a default allowance), for otherwise the service will just credit the funds to the owner of the allowance. That doesn't help the user at all because the service will credit it to the user's wallet principal. But the user needs it credited to the principal that he is using in this service frontend. Is there even any wallet out there at the moment that supports non-default allowances (with a subaccount built into them)? I think notify will be the go-to way for most users. |
So in our case that would be any changes to the internal credit balances, right? If this standard is about deposit and withdrawal then I think it should not be concerned about what services do internally with credits. If you want to log credit changes then that should be its own standard. That's a feature that is independent. It can even exists for a service that does not have deposits and withdrawals (but credits that are based on something else). |
The scenario is where a service canister which has multiple users is the user of an icrc-84 compliant other service and your are comparing: From a security perspective there seems to be no difference because the first service is always in full control of the credit. Maybe if the first service loses its data in an upgrade then the individual user credits are still maintained in the second service, is that the benefit? The price we would pay for b) is that now every icrc84 service is forced to support credit subaccounts. This comes with implementation burden and resource burden. I doubt it is worth it for the gain. With the current interface any icrc84 can already offer subaccounts on an opt-in basis. To give some context, the notify interface is already designed so that each service can define its own "actions" that a caller to notify can specify, so that the caller can make a single call that effectively becomes "notify_and_X" where X is some action (same for deposit_and_X, etc.). The original idea was to do save a second call and therefore save latency. But you can also use it to bind a specific deposit to a specific action. As an example, if the service is a minter, then notify_and_mint means I can make a deposit and mint a wrapped token in one call, saving latency for the user. For your use case, the service can offer notify_and_segregate which would mean to assign the new credit to a certain subaccount. The "action" can simply be added as a new field to the
Services can just add their custom action to optional fields in the argument record. Action-specific results can be added as new fields to the corresponding response record. |
Hmm...I'd say yes if things are that much different, but for example, Depositing to ICRC-7 could use this...and a couple other's that I've been looking at still use ICRC-1.Account formats....so I think would could at least expand it to those without having a new standard. In fact, adding ICRC-7 should be fairly easy, except that the possible values for credits are only 0 or 1. |
This is a great idea and something I wish we'd thought of for ICRC-7 etc.😬 |
Good points here...I hope we'll see approve showing up in more wallets, etc....but you are right that it can be more burdensome if the user is not using a dapp to do this procedure. |
At some point I'm going to have to sit down and make a list of all these more generic block types...This is a good thought here and I'll put it on my list. |
Very nice...I like it! Thanks for the feedback. |
Makes sense. Just double-checking: the single change of making |
Pagination will probably require us to provide a separate function that returns the current number of items. Theoretically the caller can make sequential queries until he receives back a page of shorter length than the |
[WIP] ICRC-84: Deposit and Withdrawal Standard for ICRC-1 tokens
Financial service canisters use this standard to allow users to deposit ICRC-1 tokens and withdraw them again.
An example for such a service is a DEX.
Tokens
The same service can accept deposits in one or more different ICRC-1 tokens.
A token is uniquely identified by the principal of its ICRC-1 ledger.
The list of accepted tokens can be queried with the following function.
Amounts
Amounts are specified as
nat
in the smallest unit of the ICRC-1 token.Decimals do not play a role in the interface.
To get the decimals the user has to query the ICRC-1 ledger.
Users
Users are identified by their principal.
Deposit accounts
There are two ways for a user to deposit funds to the service.
The first one is via direct transfer to a deposit account.
The second one is via an allowance,
but only if the ICRC-1 ledger supports ICRC-2.
In the direct transfer method,
users make deposits into individual deposit accounts which are subaccounts that are derived from the
User
principal in a deterministic and publicly known way.The derivation works by embedding the principal bytes right-aligned into the 32 subaccount bytes, pre-pending a length byte, and left-padding with zeros.
Requirements
The only requirement on the underlying token ledger is the ICRC-1 standard.
Since the standard deposit method is based on deposit accounts, not allowances, the ICRC-2 extension is not required.
Moreover, as will become clear below,
the deposit method is balance-based (as opposed to transaction-based).
This means it is sufficient that the service can read the balances in the deposit accounts from the underlying token ledger.
It is not required that the service can inspect individual deposit transactions by transaction id, memo or other means.
Hence, it is not required that the underlying token ledger provides an indexer, transaction history or archive.
In particular, the ICRC-3 extension is not required.
TokenInfo
For each token the service has the following public set of configuration parameters defined.
The values may change over time.
deposit_fee
specifies the fee that is deducted each time a deposit is detected and consolidated into the service's main account.The
deposit_fee
can but does not have to coincide with the transfer fee of the underlying ICRC-1 token.However, the application of the
deposit_fee
should coincide with actual transfers happening.For example, if the user makes multiple installments into the deposit account and then the service manages to consolidates them all at once into its main account then the
deposit_fee
should be charged only once.But still, the amount of the
deposit_fee
can differ from the underlying transfer fee charged by the ledger.withdrawal_fee
specifies the fee that is deducted when the user makes a withdrawal.The
withdrawal_fee
can but does not have to coincide with the transfer fee of the underlying ICRC-1 token.It is charged for each withdrawal that a user makes and that results in a successful ICRC-1 transfer.
min_deposit
is the minimal deposit that is considered valid by the service.Any balance in a deposit account that is below this value is ignored.
For example, say for the ICP token a service has defined
deposit_fee = 20_000
andmin_deposit = 100_000
.If the user makes a deposit of exactly
100_000
e8sthen
20_000
will be deducted and the user will be creditedwith
80_000
e8s.The service will empty out the user's deposit account.
As a result, the service will take in
90_000
e8s because the ICP ledger's transfer fee is10_000
e8s.If instead the user had made a deposit of
99,999
then it would have been ignored.min_deposit
must be larger thandeposit_fee
.For example, if
deposit_fee = 20_000
thenmin_deposit
must be at least20_001
.min_withdrawal
is the minimal withdrawal that a user can make.Any withdrawal request below this amount will be denied.
min_withdrawal
must be larger thanwithdrawal_fee
.For example, say for the ICP token a service has defined
withdrawal_fee = 20_000
andmin_withdrawal = 100_000
.If the user requests a withdrawal of exactly
100_000
e8sthen the user will be debited with
100_000
e8sand the service will initiate a transfer of
80_000
e8s to the user.As a result, the service will pay
90_000
e8s because the ICP ledger's transfer fee is10_000
e8s.Note: The service will never make transfers of amount 0 on the ICRC-1 ledgers even though ICRC-1 technically allows them.
This is true for consolidation of deposits and for withdrawals.
The token info can be queried with the following method.
If the specified
Token
is not supported by the service then the call will throw the async errorcanister_reject
with error message"UnknownToken"
.Credits
Credits are tracked by the service on a per-token basis.
The unit for credits is the same as the unit of the corresponsing ICRC-1 token.
However, credits are of slighly different nature than token balances even though the use the same unit.
Credits are virtual and for greater flexibility we allow credits to go negative, hence we use type
int
.A user can query his personal credit balance with the following method.
If the specified
Token
is not supported by the service then the call will throw the async errorcanister_reject
with error message"UnknownToken"
.Credit balances are private.
The above method returns the balance of the caller.
The service is not expected to distinguish non-existing users from existing ones with a credit balance of 0.
If the caller is not known to the service,
has never used the service before,
or has never used the service for the given Token before
then the method simply returns a value of zero.
For greater efficiency and to reduce query load,
there is a method to obtain a user's credits in all tokens at once.
The returned vector contains all tokens for which the caller has a non-zero credit balance.
The tokens with a zero credit balance are stripped from the response.
As before, a non-existing user is handled the same as a user with
a zero balance in all tokens.
In both cases an empty vector is returned.
Notification
There are two steps required when a user makes a deposit with the direct transfer method:
Then the service queries the ICRC-1 ledger for the balance in the deposit account and credits the user.
The second step is done via the following method.
where
A call to
icrc84_notify
notifies the service about a deposit into the deposit account of the caller for the specified token.The service is free to expand this record with additional optional fields to include an action that is to be done with the newly detected deposits.
The result type is as follows.
If the specified
Token
is not supported by the service then the call will throw the async errorcanister_reject
with error message"UnknownToken"
.The service will make a downstream call to the underlying ICRC-1 ledger before returning to the user.
If the downstream call fails then the variant
Err = CallLedgerError
is returned.The error message is not specified by this standard but is recommended to describe the async error that actually happened in the downstream call.
The service is not expected to make concurrent downstream calls for the same balance.
Hence, if the same caller calls
notify
twice concurrently for the sameToken
then the second call will returnErr = NotAvailable
.This error generally means the
notify
method is currently blocked for this caller and token, and that it should be retried later.The additional text error message returned with
NotAvailable
is not specified by this standard.If the downstream call succeeds then the method will return the
Ok
record.The
deposit_inc
field is the incremental deposit amount that was detected relative to the last known deposit balance.If no new deposit was detected then a zero value is returned.
Calls to notify are not idempotent.
If the user makes one deposit transfer and then calls
notify
twice (with no additional transfer between the two calls tonotify
)then the first call will return a non-zero
deposit_inc
valueand the second call will return zero.
If the user makes two deposit transfers and then calls
notify
(with no additional
notify
call between the two deposit transfers)then
notify
will return the sum of the two transfer amounts asdeposit_inc
.The
credit_inc
field is the incremental credit amount applied to the user as a result of this call.The value may be lower than
deposit_inc
due to the application of deposit fees, but does not have to be lower.credit_inc
is provided here because the user cannot reliably compute it himself from other data.The
credit
field is the absolute credit balance after any newly detected deposit has been credited.If multiple deposit transactions happened concurrently with calls to
notify
then the end result may depend on timing.For example, say the ledger fee is 10 and the initial credit balance of the user is 0.
If a deposit of 20 tokens is made, then
notify
is called, then another 20 tokens are deposited andnotify
is called againthen the two
notify
responses are:{ deposit_inc = 20; credit_inc = 10; credit = 10 }
,{ deposit_inc = 20; credit_inc = 10; credit = 20 }
.If the first
notify
arrives after the second deposit then two responses are:{ deposit_inc = 40; credit_inc = 30; credit = 30 }
,{ deposit_inc = 0; credit_inc = 0; credit = 30 }
.In this case the deposit fee is applied only once because the service sees it as one deposit.
The service is free to expand the response record with additional optional fields.
For example, if the service has expanded the argument record with a field specifying an action which is done after the notification
then it may want to also expand the response record with a field describing the result of that action.
Tracked balance
It was said above that
deposit_inc
returned bynotify
is the difference in deposit balance relative to the last known (= "tracked") deposit balance.The tracked deposit balance can be queried with the following method.
If the specified
Token
is not supported by the service then the call will throw the async errorcanister_reject
with error message"UnknownToken"
.Otherwise the method the returns the following type.
The
Amount
returned is the currently known balance that the caller has in the specifiedToken
.For example, say a deposit flow has been interrupted during the notification step.
The user does not know if the attempted call to
notify
has gone through or not.Then the user can query the ledger to obtain the balance in the deposit account
and can query the service to obtain the known deposit balance.
If they differ then the user must call
notify
again.Of course, the user can call
notify
directly but the two query calls are considered cheaper and faster.Hence this query method is provided.
If any concurrent downstream calls to the ledger are underway that could affect the returned
Amount
then the service returns the
Err = NotAvailable
variant.This indicates to the user to try again later.
For example, the downstream call could be a balance query (triggered by
notify
)or a consolidation transfer that relates to the caller's deposit account for the specified
Token
.Deposit
An alternative way to make deposits is via allowances.
The user has to set up an allowance for one of its subaccounts with the service's principal as the spender.
The user then calls the function
with the following argument:
token
is the Token that is being deposited.amount
is the amount that is to be drawn from the allowance into the service.Any ledger transfer fees will be added on the user account's side.
subaccount
is the user's subaccount that carries the allowance wherenull
means the default account.If successful, the call returns:
Possible errors that can occur are:
Withdrawal
The user can initiate a withdrawal with the following method.
with
The
WithdrawArgs
record specifiesthe
Token
to be withdrawn,the destination account
and the
Amount
to be taken from the caller's credits.If the specified
Token
is not supported by the service then the call will throw the async errorcanister_reject
with error message"UnknownToken"
.If the specified
Subaccount
is not 32 bytes longthen the call will throw the async error
canister_reject
with error message"InvalidSubaccount"
.Otherwise, the following result type is returned.
If the user's credit is below the requested
Amount
thenErr = InsufficientCredit
is returned.If the requested
Amount
is smaller than the Token parametermin_withdrawal
thenErr = AmountBelowMinimum
is returned.If the downstream call to the ICRC-1 ledger fails with an async error then
Err = CallLedgerError
is returned.The accompanying text message should indicate the actual async error that happened.
Otherwise the
Ok
variant is returned.It contains the
txid
on the underlying ICRC-1 ledger of the withdrawal transfer.It contains the
Amount
that was actually received by the user.In general, this
Amount
will differ from the requested amountbecause
withdrawal_fee
was deducted.FAQ
Why is
notify
access-controlled?Notify is not idempotent in its return value.
If someone else can call notify for us then we could miss an incremental value.
Notify calls are expensive for the service because of the downstream inter-canister call that they trigger.
Restricting the caller makes it easier to control or charge for that cost.
Why is the credit balance access-controlled?
Deposits are publicly visible on the ICRC-1 ledger.
Any observer can conclude from those deposit transactions
to corresponding incoming credits for the user.
But from there on further changes to the credit balance, increase or decrease, depend on the usage of the service by the user.
For example, in a DEX the credit changes would correspond to bids placed or trades executed.
We do not want to leak that information.
Why does
notify
use a balance-based approach, not transaction-based?The transaction-based approach would mean that the user "claims" a specific deposit transaction where the transaction is specified by txid and is bound to the user by memo.
The advantage is that individual deposit accounts can be avoided,
hence the consolidation step is not needed which saves fees.
The disadvantages are:
We prefer the approach that requires less state.
It makes the service leaner and easier to handle upgrades.
What are the benfits of using
notify
vs allowances?Allowances are simpler to process for the service.
Overall transaction fees are lower if an allowance is used for multiple deposits.
But allowances do not always work, for example if
The text was updated successfully, but these errors were encountered: