-
Notifications
You must be signed in to change notification settings - Fork 37
feat: a standard for accessing the transaction log #66
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
base: main
Are you sure you want to change the base?
Changes from all commits
ac5c538
c3404e0
df1a7e1
9e4787e
46a19cb
ef5a25f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """ | ||
| This module defines a macro for checking whether definitions from a markdown file match the candid interface. | ||
| """ | ||
|
|
||
| load(":didc_test.bzl", "didc_subtype_test") | ||
|
|
||
| def check_standard(name, md_file, candid_file): | ||
| """Checks whether definitions from a markdown file match the candid interface. | ||
|
|
||
| Args: | ||
| name: the prefix for generated target names. | ||
| md_file: the path to the markdown file with the standard definition. | ||
| candid_file: the Candid file with the standardized interface. | ||
| """ | ||
| generated_name = name + "_generated.did" | ||
|
|
||
| native.genrule( | ||
| name = generated_name, | ||
| srcs = [md_file], | ||
| outs = [generated_name], | ||
| cmd_bash = "$(location @lmt) $(SRCS); mv \"{}\" $@".format(candid_file), | ||
| exec_tools = ["@lmt"], | ||
| ) | ||
|
|
||
| didc_subtype_test( | ||
| name = name + "_check_generated_subtype", | ||
| did = ":" + generated_name, | ||
| previous = candid_file, | ||
| ) | ||
|
|
||
| didc_subtype_test( | ||
| name = name + "_check_source_subtype", | ||
| did = candid_file, | ||
| previous = ":" + generated_name, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| load("//bazel:check_standard.bzl", "check_standard") | ||
|
|
||
| exports_files([ | ||
| "ICRC-3.did", | ||
| ]) | ||
|
|
||
| check_standard( | ||
| name = "icrc3", | ||
| candid_file = "ICRC-3.did", | ||
| md_file = "README.md", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| type TxIndex = nat; | ||
|
|
||
| type Account = record { | ||
| owner : principal; | ||
| subaccount : opt blob; | ||
| }; | ||
|
|
||
| type GetTransactionsRequest = record { | ||
| // The index of the first tx to fetch. | ||
| start : TxIndex; | ||
| // The number of transactions to fetch. | ||
| length : nat; | ||
| }; | ||
|
|
||
| type GetTransactionsResponse = record { | ||
| // The total number of transactions in the log. | ||
| log_length : nat; | ||
|
|
||
| // List of transaction that were available in the ledger when it processed the call. | ||
| // | ||
| // The transactions form a contiguous range, with the first transaction having index | ||
| // [first_index] (see below), and the last transaction having index | ||
| // [first_index] + len(transactions) - 1. | ||
| // | ||
| // The transaction range can be an arbitrary sub-range of the originally requested range. | ||
| transactions : vec Transaction; | ||
|
|
||
| // The index of the first transaction in [transactions]. | ||
| // If the transaction vector is empty, the exact value of this field is not specified. | ||
| first_index : TxIndex; | ||
|
|
||
| // Encoding of instructions for fetching archived transactions whose indices fall into the | ||
| // requested range. | ||
| // | ||
| // For each entry `e` in [archived_transactions], `[e.from, e.from + len)` is a sub-range | ||
| // of the originally requested transaction range. | ||
| archived_transactions : vec record { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, the ledger restricts entries in the archived transactions list to the range the client requested. We didn't want to have a separate "metadata" endpoint that returns a range -> canister assignment because this API would suffer from race conditions: by the time you received the response, the assignment of the tail might have moved already. |
||
| // The index of the first archived transaction you can fetch using the [callback]. | ||
| start : TxIndex; | ||
|
|
||
| // The number of transactions you can fetch using the callback. | ||
| length : nat; | ||
|
|
||
| // The function you should call to fetch the archived transactions. | ||
| // The range of the transaction accessible using this function is given by [start] | ||
| // and [length] fields above. | ||
| callback : QueryArchiveFn; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am trying to understand why we have different interfaces for query transactions from the archive. This data should point to the suitable canister where I can call the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a possibility; this approach would allow for multi-stage archival. |
||
| }; | ||
| }; | ||
|
|
||
|
|
||
| // A prefix of the transaction range specified in the [GetTransactionsRequest] request. | ||
| type TransactionRange = record { | ||
| // A prefix of the requested transaction range. | ||
| // The index of the first transaction is equal to [GetTransactionsRequest.from]. | ||
| // | ||
| // Note that the number of transactions might be less than the requested | ||
| // [GetTransactionsRequest.length] for various reasons, for example: | ||
| // | ||
| // 1. The query might have hit the replica with an outdated state | ||
| // that doesn't have the whole range yet. | ||
| // 2. The requested range is too large to fit into a single reply. | ||
| // | ||
| // NOTE: the list of transactions can be empty if: | ||
| // | ||
| // 1. [GetTransactionsRequest.length] was zero. | ||
| // 2. [GetTransactionsRequest.from] was larger than the last transaction known to | ||
| // the canister. | ||
| transactions : vec Transaction; | ||
| }; | ||
|
|
||
| // A function for fetching archived transaction. | ||
| type QueryArchiveFn = func (GetTransactionsRequest) -> (TransactionRange) query; | ||
|
|
||
| type Transaction = record { | ||
| kind : text; | ||
| icrc1_mint : opt record { | ||
| amount : nat; | ||
| to : Account; | ||
| memo : opt blob; | ||
| created_at_time : opt nat64; | ||
| }; | ||
| icrc1_burn : opt record { | ||
| amount : nat; | ||
| from : Account; | ||
| memo : opt blob; | ||
| created_at_time : opt nat64; | ||
| }; | ||
| icrc1_transfer : opt record { | ||
| amount : nat; | ||
| from : Account; | ||
| to : Account; | ||
| memo : opt blob; | ||
| created_at_time : opt nat64; | ||
| }; | ||
| timestamp : nat64; | ||
| }; | ||
|
|
||
| service : { | ||
| icrc3_get_transactions : (GetTransactionsRequest) -> (GetTransactionsResponse) query; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| # ICRC-3: transaction log interface | ||
|
|
||
| | Status | | ||
| |:------:| | ||
| | Draft | | ||
|
|
||
| ## Abstract | ||
|
|
||
| The ICRC-3 standard specifies an API for accessing the ledger transaction log, potentially distributed across multiple canisters. | ||
|
|
||
| ## Motivation | ||
|
|
||
| Displaying the list of past transactions is among the most requested features in token wallet applications. | ||
| The ICRC-3 standard provides a minimal API providing access to the past transactions recorded on an ICRC-1—compliant ledger. | ||
|
|
||
| The following constraints guided the API design: | ||
|
|
||
| 1. Extensibility. | ||
| The API must allow the ledger to add new transaction types without breaking existing clients. | ||
|
|
||
| 1. Query-only interface. | ||
| Query methods do not modify the canister state, simplifying canister audit significantly. | ||
|
|
||
| 1. Memory efficiency. | ||
| The entire transaction log might not fit into the canister memory. | ||
| The proposed API accounts for the case when the ledger shards transactions across multiple canisters. | ||
|
|
||
| 1. Canisters as the primary consumers of the interface. | ||
| This interface is not suitable for high-performance off-chain data validation. | ||
|
|
||
| ## Methods | ||
|
|
||
| ### icrc3_get_transactions | ||
|
|
||
| Returns a list of transactions from the specified range. | ||
|
|
||
| ```candid "Methods" += | ||
| icrc3_get_transactions : (GetTransactionsRequest) -> (GetTransactionsResponse) query; | ||
| ``` | ||
|
|
||
| The ledger identifies transactions by their sequence number. | ||
| The ledger creates a transaction for each successful state mutation. | ||
|
|
||
| ```candid "Type definitions" += | ||
| type TxIndex = nat; | ||
| ``` | ||
|
|
||
| The transaction type is a record with two required fields: | ||
| 1. The `kind` field contains the transaction type (`icrc1_mint`, `icrc1_burn`, `icrc1_transfer`, etc.). | ||
| 2. The `timestamp` field contains the IC time at which the ledger accepted the transaction. | ||
| All other fields are optional and correspond to different transaction types. | ||
|
|
||
| The value of the `kind` field determines which field in the transaction record has a value. | ||
| For example, if `kind = "icrc1_transfer"`, then the `icrc1_transfer` field contains the transaction details. | ||
|
|
||
| > **Note** | ||
| > One of the reasons we use record to emulate a transaction variant is that as of October 2022, Candid does not support extensible variants. | ||
| > See https://github.com/dfinity/candid/issues/295 for more detail. | ||
|
|
||
| ```candid "Type definitions" += | ||
| type Account = record { owner : principal; subaccount : opt blob; }; | ||
|
|
||
| type Transaction = record { | ||
| kind : text; // "icrc1_mint" | "icrc1_burn" | "icrc1_transfer" | ... | ||
| icrc1_mint : opt record { | ||
| amount : nat; | ||
| to : Account; | ||
| memo : opt blob; | ||
| created_at_time : opt nat64; | ||
| }; | ||
| icrc1_burn : opt record { | ||
| amount : nat; | ||
| from : Account; | ||
| memo : opt blob; | ||
| created_at_time : opt nat64; | ||
| }; | ||
| icrc1_transfer : opt record { | ||
| amount : nat; | ||
| from : Account; | ||
| to : Account; | ||
| memo : opt blob; | ||
| created_at_time : opt nat64; | ||
| }; | ||
| timestamp : nat64; | ||
| }; | ||
| ``` | ||
|
|
||
| The client specifies the index of the first transaction and the number of transactions to fetch. | ||
|
|
||
| The ledger returns a record with the following fields: | ||
| * The `log_length` field is the total number of transactions in the log. | ||
| * The `transactions` field is an _infix_ of the requested transaction range. | ||
| * The `first_index` field is the index of the first transaction in the `transaction` field. | ||
| If the `transactions` field is an empty vector, the value of `first_index` is unspecified. | ||
| * The `archived_transactions` field contains instructions for fetching the _prefix_ of the requested range. | ||
| Each entry indicates that the client can fetch transactions in the range `[start, start+length-1]` with the specified `callback` method reference. | ||
|
|
||
| ```candid "Type definitions" += | ||
| type GetTransactionsRequest = record { start : TxIndex; length : nat }; | ||
|
|
||
| type GetTransactionsResponse = record { | ||
| log_length : nat; | ||
| transactions : vec Transaction; | ||
| first_index : TxIndex; | ||
| archived_transactions : vec record { | ||
| start : TxIndex; | ||
| length : nat; | ||
| callback : QueryArchiveFn; | ||
| }; | ||
| }; | ||
| ``` | ||
|
|
||
| Some of the transactions in the range might be "archived", i.e., reside in other canisters. | ||
| All transaction ranges that `{ start; length }` tuples in the `archived_transactions` field form MUST have a non-zero intersection with the requested range. | ||
|
|
||
| ```candid "Type definitions" += | ||
| type QueryArchiveFn = func (GetTransactionsRequest) -> (TransactionRange) query; | ||
|
|
||
| type TransactionRange = record { transactions : vec Transaction; }; | ||
| ``` | ||
|
|
||
| Ledger and archives MAY return fewer transactions than the client requested. | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Synchronizing the state | ||
|
|
||
| The following example demonstrates how a client can synchronize with the ledger state distributed across multiple canisters using the proposed interface. | ||
|
|
||
| 1. The client calls `icrc3_get_transactions({ start = 0; length = 10_000 })` on the ledger. | ||
| 2. The ledger has 5_500 transactions and happens to have transactions `4_000..5_499` in memory. | ||
| However, the ledger implementors decided not to return more than 1_000 per request. | ||
| 3. The ledger returns the following value. | ||
| ```candid | ||
| record { | ||
| log_length = 5_500; | ||
| transactions = vec { /* transactions 4_000..4_999 */ }; | ||
| first_index = 4_000; | ||
| archived_transactions = vec { | ||
| record { start = 0; length = 4_000; callback = "4kydj-ryaaa-aaaag-qaf7a-cai"."get_archived_transactions" } | ||
| } | ||
| } | ||
| ``` | ||
| 4. The client appends transactions `4_000..4_999` to the local buffer and issues a follow-up call to the archive: `get_archived_transactions({ start = 0; length = 4_000 })`. | ||
| 5. The archive implementors decided not to return more than 2_000 transactions per request. | ||
| The archive returns the following value. | ||
| ```candid | ||
| record { transactions : vec { /* transactions 0..1_999 */ } } | ||
| ``` | ||
| 6. The client appends transactions `0..1_999` to the buffer. | ||
| Since the archive returned fewer blocks than requested, the client repeats the call with a different range: `get_archived_transactions({ start = 2_000; length = 2_000 })`. | ||
| 7. The archive returns the following value. | ||
| ```candid | ||
| record { transactions : vec { /* transactions 2_000..3_999 */ } } | ||
| ``` | ||
| 8. The client appends transactions `2_000..3_999` to the buffer. | ||
| Since there are more transactions to fetch according to the `log_length` value, the client makes a follow-up call to the ledger: `icrc3_get_transactions({ start = 5_000; length = 1_000 })`. | ||
| 9. The ledger accepted a hundred more transactions in the meantime and archived transactions `4_000..4_999`. | ||
| It returns the following value: | ||
| ```candid | ||
| record { | ||
| log_length = 5_600; | ||
| transactions = vec { /* transactions 5_000..5_599 */ }; | ||
| first_index = 5_000; | ||
| archived_transactions = vec {}; | ||
| } | ||
| ``` | ||
| 10. The client adds transactions `5_000..5_599` to the buffer. | ||
| It is synced with the ledger now. | ||
|
|
||
| <!-- | ||
| ```candid ICRC-3.did += | ||
| <<<Type definitions>>> | ||
|
|
||
| service : { | ||
| <<<Methods>>> | ||
| } | ||
| ``` | ||
| --> |
Uh oh!
There was an error while loading. Please reload this page.