mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-03-24 03:03:09 +00:00
Merge fdd00c10603c24692240f21a568f525123389c2a into 6f77c75b9c165d666fcbd4dab7e3988442791595
This commit is contained in:
commit
a8faea1b2b
22
Cargo.lock
generated
22
Cargo.lock
generated
@ -727,6 +727,24 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ata_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nssa_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ata_program"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ata_core",
|
||||
"nssa_core",
|
||||
"token_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-polyfill"
|
||||
version = "1.0.3"
|
||||
@ -3551,6 +3569,7 @@ name = "integration_tests"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ata_core",
|
||||
"bytesize",
|
||||
"common",
|
||||
"env_logger",
|
||||
@ -5882,6 +5901,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"amm_core",
|
||||
"amm_program",
|
||||
"ata_core",
|
||||
"ata_program",
|
||||
"nssa_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
@ -8649,6 +8670,7 @@ dependencies = [
|
||||
"amm_core",
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"ata_core",
|
||||
"base58",
|
||||
"clap",
|
||||
"common",
|
||||
|
||||
@ -17,6 +17,8 @@ members = [
|
||||
"programs/amm",
|
||||
"programs/token/core",
|
||||
"programs/token",
|
||||
"programs/associated_token_account/core",
|
||||
"programs/associated_token_account",
|
||||
"sequencer/core",
|
||||
"sequencer/service",
|
||||
"sequencer/service/protocol",
|
||||
@ -57,6 +59,8 @@ token_core = { path = "programs/token/core" }
|
||||
token_program = { path = "programs/token" }
|
||||
amm_core = { path = "programs/amm/core" }
|
||||
amm_program = { path = "programs/amm" }
|
||||
ata_core = { path = "programs/associated_token_account/core" }
|
||||
ata_program = { path = "programs/associated_token_account" }
|
||||
test_program_methods = { path = "test_program_methods" }
|
||||
bedrock_client = { path = "bedrock_client" }
|
||||
|
||||
|
||||
Binary file not shown.
BIN
artifacts/program_methods/associated_token_account.bin
Normal file
BIN
artifacts/program_methods/associated_token_account.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
369
docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md
Normal file
369
docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md
Normal file
@ -0,0 +1,369 @@
|
||||
# Associated Token Accounts (ATAs)
|
||||
|
||||
This tutorial covers Associated Token Accounts (ATAs). An ATA lets you derive a unique token holding address from an owner account and a token definition — no need to create and track holding accounts manually. Given the same inputs, anyone can compute the same ATA address without a network call. By the end, you will have practiced:
|
||||
|
||||
1. Deriving ATA addresses locally.
|
||||
2. Creating an ATA.
|
||||
3. Sending tokens via ATAs.
|
||||
4. Burning tokens from an ATA.
|
||||
5. Listing ATAs across multiple token definitions.
|
||||
6. Creating an ATA with a private owner.
|
||||
7. Sending tokens from a private owner's ATA.
|
||||
8. Burning tokens from a private owner's ATA.
|
||||
|
||||
> [!Important]
|
||||
> This tutorial assumes you have completed the [wallet-setup](wallet-setup.md) and [custom-tokens](custom-tokens.md) tutorials. You need a running wallet with accounts and at least one token definition.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Deploy the ATA program
|
||||
|
||||
Unlike the Token program (which is built-in), the ATA program must be deployed before you can use it. The pre-built binary is included in the repository:
|
||||
|
||||
```bash
|
||||
wallet deploy-program artifacts/program_methods/associated_token_account.bin
|
||||
```
|
||||
|
||||
> [!Note]
|
||||
> Program deployment is idempotent — if the ATA program has already been deployed (e.g. by another user on the same network), the command is a no-op.
|
||||
|
||||
You can verify the deployment succeeded by running any `wallet ata` command. If the program is not deployed, commands that submit transactions will fail.
|
||||
|
||||
The CLI provides commands to work with the ATA program. Run `wallet ata` to see the options:
|
||||
|
||||
```bash
|
||||
Commands:
|
||||
address Derive and print the Associated Token Account address (local only, no network)
|
||||
create Create (or idempotently no-op) the Associated Token Account
|
||||
send Send tokens from owner's ATA to a recipient
|
||||
burn Burn tokens from holder's ATA
|
||||
list List all ATAs for a given owner across multiple token definitions
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
```
|
||||
|
||||
## 1. How ATA addresses work
|
||||
|
||||
An ATA address is deterministically derived from two inputs:
|
||||
|
||||
1. The **owner** account ID.
|
||||
2. The **token definition** account ID.
|
||||
|
||||
The derivation works as follows:
|
||||
|
||||
```
|
||||
seed = SHA256(owner_id || definition_id)
|
||||
ata_address = AccountId::from((ata_program_id, seed))
|
||||
```
|
||||
|
||||
Because the computation is pure, anyone who knows the owner and definition can reproduce the exact same ATA address — no network call required.
|
||||
|
||||
> [!Note]
|
||||
> All ATA commands that submit transactions accept a privacy prefix on the owner/holder argument — `Public/` for public accounts and `Private/` for private accounts. Using `Private/` generates a zero-knowledge proof locally and submits only the proof to the sequencer, keeping the owner's identity off-chain.
|
||||
|
||||
## 2. Deriving an ATA address (`wallet ata address`)
|
||||
|
||||
The `address` subcommand computes the ATA address locally without submitting a transaction.
|
||||
|
||||
### a. Set up an owner and token definition
|
||||
|
||||
If you already have a public account and a token definition from the custom-tokens tutorial, you can reuse them. Otherwise, create them now:
|
||||
|
||||
```bash
|
||||
wallet account new public
|
||||
|
||||
# Output:
|
||||
Generated new account with account_id Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB
|
||||
```
|
||||
|
||||
```bash
|
||||
wallet account new public
|
||||
|
||||
# Output:
|
||||
Generated new account with account_id Public/3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4
|
||||
```
|
||||
|
||||
```bash
|
||||
wallet token new \
|
||||
--name MYTOKEN \
|
||||
--total-supply 10000 \
|
||||
--definition-account-id Public/3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4 \
|
||||
--supply-account-id Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB
|
||||
```
|
||||
|
||||
### b. Derive the ATA address
|
||||
|
||||
```bash
|
||||
wallet ata address \
|
||||
--owner 5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4
|
||||
|
||||
# Output:
|
||||
7a2Bf9cKLm3XpRtH1wDqZs8vYjN4eU6gAoFxW5kMnE2R
|
||||
```
|
||||
|
||||
> [!Note]
|
||||
> This is a pure computation — no transaction is submitted and no network connection is needed. The same inputs will always produce the same output.
|
||||
|
||||
## 3. Creating an ATA (`wallet ata create`)
|
||||
|
||||
Before an ATA can hold tokens it must be created on-chain. The `create` subcommand submits a transaction that initializes the ATA. If it already exists, the operation is a no-op.
|
||||
|
||||
### a. Create the ATA
|
||||
|
||||
```bash
|
||||
wallet ata create \
|
||||
--owner Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4
|
||||
```
|
||||
|
||||
### b. Inspect the ATA
|
||||
|
||||
Use the ATA address derived in the previous section:
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/7a2Bf9cKLm3XpRtH1wDqZs8vYjN4eU6gAoFxW5kMnE2R
|
||||
|
||||
# Output:
|
||||
Holding account owned by ata program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":0}
|
||||
```
|
||||
|
||||
> [!Tip]
|
||||
> Creation is idempotent — running the same command again is a no-op.
|
||||
|
||||
## 4. Sending tokens via ATA (`wallet ata send`)
|
||||
|
||||
The `send` subcommand transfers tokens from the owner's ATA to a recipient account.
|
||||
|
||||
### a. Fund the ATA
|
||||
|
||||
First, move tokens into the ATA from the supply account created earlier:
|
||||
|
||||
```bash
|
||||
wallet token send \
|
||||
--from Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--to Public/7a2Bf9cKLm3XpRtH1wDqZs8vYjN4eU6gAoFxW5kMnE2R \
|
||||
--amount 5000
|
||||
```
|
||||
|
||||
### b. Create a recipient account
|
||||
|
||||
```bash
|
||||
wallet account new public
|
||||
|
||||
# Output:
|
||||
Generated new account with account_id Public/9Ht4Kv8pYmW2rXjN6dFcQsA7bEoLf3gUZx1wDnR5eTi
|
||||
```
|
||||
|
||||
### c. Send tokens from the ATA to the recipient
|
||||
|
||||
```bash
|
||||
wallet ata send \
|
||||
--from Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4 \
|
||||
--to 9Ht4Kv8pYmW2rXjN6dFcQsA7bEoLf3gUZx1wDnR5eTi \
|
||||
--amount 2000
|
||||
```
|
||||
|
||||
### d. Verify balances
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/7a2Bf9cKLm3XpRtH1wDqZs8vYjN4eU6gAoFxW5kMnE2R
|
||||
|
||||
# Output:
|
||||
Holding account owned by ata program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":3000}
|
||||
```
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/9Ht4Kv8pYmW2rXjN6dFcQsA7bEoLf3gUZx1wDnR5eTi
|
||||
|
||||
# Output:
|
||||
Holding account owned by token program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":2000}
|
||||
```
|
||||
|
||||
## 5. Burning tokens from an ATA (`wallet ata burn`)
|
||||
|
||||
The `burn` subcommand destroys tokens held in the owner's ATA, reducing the token's total supply.
|
||||
|
||||
### a. Burn tokens
|
||||
|
||||
```bash
|
||||
wallet ata burn \
|
||||
--holder Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4 \
|
||||
--amount 500
|
||||
```
|
||||
|
||||
### b. Verify the reduced balance
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/7a2Bf9cKLm3XpRtH1wDqZs8vYjN4eU6gAoFxW5kMnE2R
|
||||
|
||||
# Output:
|
||||
Holding account owned by ata program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":2500}
|
||||
```
|
||||
|
||||
## 6. Listing ATAs (`wallet ata list`)
|
||||
|
||||
The `list` subcommand queries ATAs for a given owner across one or more token definitions.
|
||||
|
||||
### a. Create a second token and ATA
|
||||
|
||||
Create a second token definition so there are multiple ATAs to list:
|
||||
|
||||
```bash
|
||||
wallet account new public
|
||||
|
||||
# Output:
|
||||
Generated new account with account_id Public/BxR3Lm7YkWp9vNs2hD4qJcTfA8eUoZ6gKn1wXjM5rFi
|
||||
```
|
||||
|
||||
```bash
|
||||
wallet account new public
|
||||
|
||||
# Output:
|
||||
Generated new account with account_id Public/Ck8mVp4YhWn2rXjD6dFsQtA7bEoLf3gUZx1wDnR9eTs
|
||||
```
|
||||
|
||||
```bash
|
||||
wallet token new \
|
||||
--name OTHERTOKEN \
|
||||
--total-supply 5000 \
|
||||
--definition-account-id Public/BxR3Lm7YkWp9vNs2hD4qJcTfA8eUoZ6gKn1wXjM5rFi \
|
||||
--supply-account-id Public/Ck8mVp4YhWn2rXjD6dFsQtA7bEoLf3gUZx1wDnR9eTs
|
||||
```
|
||||
|
||||
Create an ATA for the second token:
|
||||
|
||||
```bash
|
||||
wallet ata create \
|
||||
--owner Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--token-definition BxR3Lm7YkWp9vNs2hD4qJcTfA8eUoZ6gKn1wXjM5rFi
|
||||
```
|
||||
|
||||
### b. List ATAs for both token definitions
|
||||
|
||||
```bash
|
||||
wallet ata list \
|
||||
--owner 5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--token-definition \
|
||||
3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4 \
|
||||
BxR3Lm7YkWp9vNs2hD4qJcTfA8eUoZ6gKn1wXjM5rFi
|
||||
|
||||
# Output:
|
||||
ATA 7a2Bf9cKLm3XpRtH1wDqZs8vYjN4eU6gAoFxW5kMnE2R (definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4): balance 2500
|
||||
ATA 4nPxKd8YmW7rVsH2jDfQcA9bEoLf6gUZx3wTnR1eMs5 (definition BxR3Lm7YkWp9vNs2hD4qJcTfA8eUoZ6gKn1wXjM5rFi): balance 0
|
||||
```
|
||||
|
||||
> [!Note]
|
||||
> The `list` command derives each ATA address locally and fetches its on-chain state. If an ATA has not been created for a given definition, it prints "No ATA for definition ..." instead.
|
||||
|
||||
## 7. Private owner operations
|
||||
|
||||
All three ATA operations — `create`, `send`, and `burn` — support private owner accounts. Passing a `Private/` prefix on the owner argument switches the wallet into privacy-preserving mode:
|
||||
|
||||
1. The wallet builds the transaction locally.
|
||||
2. The ATA program is executed inside the RISC0 ZK VM to generate a proof.
|
||||
3. The proof, the updated ATA state (in plaintext), and an encrypted update for the owner's private account are submitted to the sequencer.
|
||||
4. The sequencer verifies the proof, writes the ATA state change to the public chain, and records the owner's new commitment in the nullifier set.
|
||||
|
||||
The result is that the ATA account and its token balance are **fully public** — anyone can see them. What stays private is the link between the ATA and its owner: the proof demonstrates that someone with the correct private key authorized the operation, but reveals nothing about which account that was.
|
||||
|
||||
> [!Note]
|
||||
> The ATA address is derived from `SHA256(owner_id || definition_id)`. Because SHA256 is one-way, the ATA address does not reveal the owner's identity. However, if the owner's account ID becomes known for any other reason, all of their ATAs across every token definition can be enumerated by anyone.
|
||||
|
||||
### a. Create a private account
|
||||
|
||||
```bash
|
||||
wallet account new private
|
||||
|
||||
# Output:
|
||||
Generated new account with account_id Private/HkR7Lm2YnWp4vNs8hD3qJcTfA6eUoZ9gKn5wXjM1rFi
|
||||
```
|
||||
|
||||
### b. Create the ATA for the private owner
|
||||
|
||||
Pass `Private/` on `--owner`. The token definition account has no privacy prefix — it is always a public account.
|
||||
|
||||
```bash
|
||||
wallet ata create \
|
||||
--owner Private/HkR7Lm2YnWp4vNs8hD3qJcTfA6eUoZ9gKn5wXjM1rFi \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4
|
||||
```
|
||||
|
||||
> [!Note]
|
||||
> Proof generation runs locally in the RISC0 ZK VM and can take up to a minute on first run.
|
||||
|
||||
### c. Verify the ATA was created
|
||||
|
||||
Derive the ATA address using the raw account ID (no privacy prefix):
|
||||
|
||||
```bash
|
||||
wallet ata address \
|
||||
--owner HkR7Lm2YnWp4vNs8hD3qJcTfA6eUoZ9gKn5wXjM1rFi \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4
|
||||
|
||||
# Output:
|
||||
2pQxNf7YkWm3rVsH8jDcQaA4bEoLf9gUZx6wTnR2eMs1
|
||||
```
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/2pQxNf7YkWm3rVsH8jDcQaA4bEoLf9gUZx6wTnR2eMs1
|
||||
|
||||
# Output:
|
||||
Holding account owned by ata program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":0}
|
||||
```
|
||||
|
||||
### d. Fund the ATA
|
||||
|
||||
The ATA is a public account. Fund it with a direct token transfer from any public holding account:
|
||||
|
||||
```bash
|
||||
wallet token send \
|
||||
--from Public/5FkBei8HYoSUNqh9rWCrJDnSZE5FJfGiWmTvhgBx3qTB \
|
||||
--to Public/2pQxNf7YkWm3rVsH8jDcQaA4bEoLf9gUZx6wTnR2eMs1 \
|
||||
--amount 500
|
||||
```
|
||||
|
||||
### e. Send tokens from the private owner's ATA
|
||||
|
||||
```bash
|
||||
wallet ata send \
|
||||
--from Private/HkR7Lm2YnWp4vNs8hD3qJcTfA6eUoZ9gKn5wXjM1rFi \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4 \
|
||||
--to 9Ht4Kv8pYmW2rXjN6dFcQsA7bEoLf3gUZx1wDnR5eTi \
|
||||
--amount 200
|
||||
```
|
||||
|
||||
Verify the ATA balance decreased:
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/2pQxNf7YkWm3rVsH8jDcQaA4bEoLf9gUZx6wTnR2eMs1
|
||||
|
||||
# Output:
|
||||
Holding account owned by ata program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":300}
|
||||
```
|
||||
|
||||
### f. Burn tokens from the private owner's ATA
|
||||
|
||||
```bash
|
||||
wallet ata burn \
|
||||
--holder Private/HkR7Lm2YnWp4vNs8hD3qJcTfA6eUoZ9gKn5wXjM1rFi \
|
||||
--token-definition 3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4 \
|
||||
--amount 100
|
||||
```
|
||||
|
||||
Verify the balance and token supply:
|
||||
|
||||
```bash
|
||||
wallet account get --account-id Public/2pQxNf7YkWm3rVsH8jDcQaA4bEoLf9gUZx6wTnR2eMs1
|
||||
|
||||
# Output:
|
||||
Holding account owned by ata program
|
||||
{"account_type":"Token holding","definition_id":"3YpK8RvVzWm6Q4h2nDAbxJfLmuRqkEkFP9C7UwTdGvE4","balance":200}
|
||||
```
|
||||
@ -18,6 +18,7 @@ key_protocol.workspace = true
|
||||
indexer_service.workspace = true
|
||||
serde_json.workspace = true
|
||||
token_core.workspace = true
|
||||
ata_core.workspace = true
|
||||
indexer_service_rpc.workspace = true
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
wallet-ffi.workspace = true
|
||||
|
||||
656
integration_tests/tests/ata.rs
Normal file
656
integration_tests/tests/ata.rs
Normal file
@ -0,0 +1,656 @@
|
||||
#![expect(
|
||||
clippy::shadow_unrelated,
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "We don't care about these in tests"
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use integration_tests::{
|
||||
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_private_account_id,
|
||||
format_public_account_id, verify_commitment_is_in_state,
|
||||
};
|
||||
use log::info;
|
||||
use nssa::program::Program;
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
use tokio::test;
|
||||
use wallet::cli::{
|
||||
Command, SubcommandReturnValue,
|
||||
account::{AccountSubcommand, NewSubcommand},
|
||||
programs::{ata::AtaSubcommand, token::TokenProgramAgnosticSubcommand},
|
||||
};
|
||||
|
||||
/// Create a public account and return its ID.
|
||||
async fn new_public_account(ctx: &mut TestContext) -> Result<nssa::AccountId> {
|
||||
let result = wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
|
||||
cci: None,
|
||||
label: None,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
|
||||
anyhow::bail!("Expected RegisterAccount return value");
|
||||
};
|
||||
Ok(account_id)
|
||||
}
|
||||
|
||||
/// Create a private account and return its ID.
|
||||
async fn new_private_account(ctx: &mut TestContext) -> Result<nssa::AccountId> {
|
||||
let result = wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
|
||||
cci: None,
|
||||
label: None,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
|
||||
anyhow::bail!("Expected RegisterAccount return value");
|
||||
};
|
||||
Ok(account_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn create_ata_initializes_holding_account() -> Result<()> {
|
||||
let mut ctx = TestContext::new().await?;
|
||||
|
||||
let definition_account_id = new_public_account(&mut ctx).await?;
|
||||
let supply_account_id = new_public_account(&mut ctx).await?;
|
||||
let owner_account_id = new_public_account(&mut ctx).await?;
|
||||
|
||||
// Create a fungible token
|
||||
let total_supply = 100_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::New {
|
||||
definition_account_id: format_public_account_id(definition_account_id),
|
||||
supply_account_id: format_public_account_id(supply_account_id),
|
||||
name: "TEST".to_owned(),
|
||||
total_supply,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Create the ATA for owner + definition
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_public_account_id(owner_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Derive expected ATA address and check on-chain state
|
||||
let ata_program_id = Program::ata().id();
|
||||
let ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_account_id, definition_account_id),
|
||||
);
|
||||
|
||||
let ata_acc = ctx
|
||||
.sequencer_client()
|
||||
.get_account(ata_id)
|
||||
.await
|
||||
.context("ATA account not found")?;
|
||||
|
||||
assert_eq!(ata_acc.program_owner, Program::token().id());
|
||||
let holding = TokenHolding::try_from(&ata_acc.data)?;
|
||||
assert_eq!(
|
||||
holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: 0,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn create_ata_is_idempotent() -> Result<()> {
|
||||
let mut ctx = TestContext::new().await?;
|
||||
|
||||
let definition_account_id = new_public_account(&mut ctx).await?;
|
||||
let supply_account_id = new_public_account(&mut ctx).await?;
|
||||
let owner_account_id = new_public_account(&mut ctx).await?;
|
||||
|
||||
// Create a fungible token
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::New {
|
||||
definition_account_id: format_public_account_id(definition_account_id),
|
||||
supply_account_id: format_public_account_id(supply_account_id),
|
||||
name: "TEST".to_owned(),
|
||||
total_supply: 100,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Create the ATA once
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_public_account_id(owner_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Create the ATA a second time — must succeed (idempotent)
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_public_account_id(owner_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// State must be unchanged
|
||||
let ata_program_id = Program::ata().id();
|
||||
let ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_account_id, definition_account_id),
|
||||
);
|
||||
|
||||
let ata_acc = ctx
|
||||
.sequencer_client()
|
||||
.get_account(ata_id)
|
||||
.await
|
||||
.context("ATA account not found")?;
|
||||
|
||||
assert_eq!(ata_acc.program_owner, Program::token().id());
|
||||
let holding = TokenHolding::try_from(&ata_acc.data)?;
|
||||
assert_eq!(
|
||||
holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: 0,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn transfer_and_burn_via_ata() -> Result<()> {
|
||||
let mut ctx = TestContext::new().await?;
|
||||
|
||||
let definition_account_id = new_public_account(&mut ctx).await?;
|
||||
let supply_account_id = new_public_account(&mut ctx).await?;
|
||||
let sender_account_id = new_public_account(&mut ctx).await?;
|
||||
let recipient_account_id = new_public_account(&mut ctx).await?;
|
||||
|
||||
let total_supply = 1000_u128;
|
||||
|
||||
// Create a fungible token, supply goes to supply_account_id
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::New {
|
||||
definition_account_id: format_public_account_id(definition_account_id),
|
||||
supply_account_id: format_public_account_id(supply_account_id),
|
||||
name: "TEST".to_owned(),
|
||||
total_supply,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Derive ATA addresses
|
||||
let ata_program_id = Program::ata().id();
|
||||
let sender_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(sender_account_id, definition_account_id),
|
||||
);
|
||||
let recipient_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(recipient_account_id, definition_account_id),
|
||||
);
|
||||
|
||||
// Create ATAs for sender and recipient
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_public_account_id(sender_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_public_account_id(recipient_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Fund sender's ATA from the supply account (direct token transfer)
|
||||
let fund_amount = 200_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::Send {
|
||||
from: format_public_account_id(supply_account_id),
|
||||
to: Some(format_public_account_id(sender_ata_id)),
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: fund_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Transfer from sender's ATA to recipient's ATA via the ATA program
|
||||
let transfer_amount = 50_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Send {
|
||||
from: format_public_account_id(sender_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
to: recipient_ata_id.to_string(),
|
||||
amount: transfer_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Verify sender ATA balance decreased
|
||||
let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?;
|
||||
let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?;
|
||||
assert_eq!(
|
||||
sender_holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: fund_amount - transfer_amount,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify recipient ATA balance increased
|
||||
let recipient_ata_acc = ctx.sequencer_client().get_account(recipient_ata_id).await?;
|
||||
let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?;
|
||||
assert_eq!(
|
||||
recipient_holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: transfer_amount,
|
||||
}
|
||||
);
|
||||
|
||||
// Burn from sender's ATA
|
||||
let burn_amount = 30_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Burn {
|
||||
holder: format_public_account_id(sender_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
amount: burn_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Verify sender ATA balance after burn
|
||||
let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?;
|
||||
let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?;
|
||||
assert_eq!(
|
||||
sender_holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: fund_amount - transfer_amount - burn_amount,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the token definition total_supply decreased by burn_amount
|
||||
let definition_acc = ctx
|
||||
.sequencer_client()
|
||||
.get_account(definition_account_id)
|
||||
.await?;
|
||||
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
|
||||
assert_eq!(
|
||||
token_definition,
|
||||
TokenDefinition::Fungible {
|
||||
name: "TEST".to_owned(),
|
||||
total_supply: total_supply - burn_amount,
|
||||
metadata_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn create_ata_with_private_owner() -> Result<()> {
|
||||
let mut ctx = TestContext::new().await?;
|
||||
|
||||
let definition_account_id = new_public_account(&mut ctx).await?;
|
||||
let supply_account_id = new_public_account(&mut ctx).await?;
|
||||
let owner_account_id = new_private_account(&mut ctx).await?;
|
||||
|
||||
// Create a fungible token
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::New {
|
||||
definition_account_id: format_public_account_id(definition_account_id),
|
||||
supply_account_id: format_public_account_id(supply_account_id),
|
||||
name: "TEST".to_owned(),
|
||||
total_supply: 100,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Create the ATA for the private owner + definition
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_private_account_id(owner_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Derive expected ATA address and check on-chain state
|
||||
let ata_program_id = Program::ata().id();
|
||||
let ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_account_id, definition_account_id),
|
||||
);
|
||||
|
||||
let ata_acc = ctx
|
||||
.sequencer_client()
|
||||
.get_account(ata_id)
|
||||
.await
|
||||
.context("ATA account not found")?;
|
||||
|
||||
assert_eq!(ata_acc.program_owner, Program::token().id());
|
||||
let holding = TokenHolding::try_from(&ata_acc.data)?;
|
||||
assert_eq!(
|
||||
holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the private owner's commitment is in state
|
||||
let commitment = ctx
|
||||
.wallet()
|
||||
.get_private_account_commitment(owner_account_id)
|
||||
.context("Private owner commitment not found")?;
|
||||
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn transfer_via_ata_private_owner() -> Result<()> {
|
||||
let mut ctx = TestContext::new().await?;
|
||||
|
||||
let definition_account_id = new_public_account(&mut ctx).await?;
|
||||
let supply_account_id = new_public_account(&mut ctx).await?;
|
||||
let sender_account_id = new_private_account(&mut ctx).await?;
|
||||
let recipient_account_id = new_public_account(&mut ctx).await?;
|
||||
|
||||
let total_supply = 1000_u128;
|
||||
|
||||
// Create a fungible token
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::New {
|
||||
definition_account_id: format_public_account_id(definition_account_id),
|
||||
supply_account_id: format_public_account_id(supply_account_id),
|
||||
name: "TEST".to_owned(),
|
||||
total_supply,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Derive ATA addresses
|
||||
let ata_program_id = Program::ata().id();
|
||||
let sender_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(sender_account_id, definition_account_id),
|
||||
);
|
||||
let recipient_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(recipient_account_id, definition_account_id),
|
||||
);
|
||||
|
||||
// Create ATAs for sender (private owner) and recipient (public owner)
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_private_account_id(sender_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_public_account_id(recipient_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Fund sender's ATA from the supply account (direct token transfer)
|
||||
let fund_amount = 200_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::Send {
|
||||
from: format_public_account_id(supply_account_id),
|
||||
to: Some(format_public_account_id(sender_ata_id)),
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: fund_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Transfer from sender's ATA (private owner) to recipient's ATA
|
||||
let transfer_amount = 50_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Send {
|
||||
from: format_private_account_id(sender_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
to: recipient_ata_id.to_string(),
|
||||
amount: transfer_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Verify sender ATA balance decreased
|
||||
let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?;
|
||||
let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?;
|
||||
assert_eq!(
|
||||
sender_holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: fund_amount - transfer_amount,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify recipient ATA balance increased
|
||||
let recipient_ata_acc = ctx.sequencer_client().get_account(recipient_ata_id).await?;
|
||||
let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?;
|
||||
assert_eq!(
|
||||
recipient_holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: transfer_amount,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the private sender's commitment is in state
|
||||
let commitment = ctx
|
||||
.wallet()
|
||||
.get_private_account_commitment(sender_account_id)
|
||||
.context("Private sender commitment not found")?;
|
||||
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn burn_via_ata_private_owner() -> Result<()> {
|
||||
let mut ctx = TestContext::new().await?;
|
||||
|
||||
let definition_account_id = new_public_account(&mut ctx).await?;
|
||||
let supply_account_id = new_public_account(&mut ctx).await?;
|
||||
let holder_account_id = new_private_account(&mut ctx).await?;
|
||||
|
||||
let total_supply = 500_u128;
|
||||
|
||||
// Create a fungible token
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::New {
|
||||
definition_account_id: format_public_account_id(definition_account_id),
|
||||
supply_account_id: format_public_account_id(supply_account_id),
|
||||
name: "TEST".to_owned(),
|
||||
total_supply,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Derive holder's ATA address
|
||||
let ata_program_id = Program::ata().id();
|
||||
let holder_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(holder_account_id, definition_account_id),
|
||||
);
|
||||
|
||||
// Create ATA for the private holder
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Create {
|
||||
owner: format_private_account_id(holder_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Fund holder's ATA from the supply account
|
||||
let fund_amount = 300_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Token(TokenProgramAgnosticSubcommand::Send {
|
||||
from: format_public_account_id(supply_account_id),
|
||||
to: Some(format_public_account_id(holder_ata_id)),
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: fund_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Burn from holder's ATA (private owner)
|
||||
let burn_amount = 100_u128;
|
||||
wallet::cli::execute_subcommand(
|
||||
ctx.wallet_mut(),
|
||||
Command::Ata(AtaSubcommand::Burn {
|
||||
holder: format_private_account_id(holder_account_id),
|
||||
token_definition: definition_account_id.to_string(),
|
||||
amount: burn_amount,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
// Verify holder ATA balance after burn
|
||||
let holder_ata_acc = ctx.sequencer_client().get_account(holder_ata_id).await?;
|
||||
let holder_holding = TokenHolding::try_from(&holder_ata_acc.data)?;
|
||||
assert_eq!(
|
||||
holder_holding,
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_account_id,
|
||||
balance: fund_amount - burn_amount,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the token definition total_supply decreased by burn_amount
|
||||
let definition_acc = ctx
|
||||
.sequencer_client()
|
||||
.get_account(definition_account_id)
|
||||
.await?;
|
||||
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
|
||||
assert_eq!(
|
||||
token_definition,
|
||||
TokenDefinition::Fungible {
|
||||
name: "TEST".to_owned(),
|
||||
total_supply: total_supply - burn_amount,
|
||||
metadata_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the private holder's commitment is in state
|
||||
let commitment = ctx
|
||||
.wallet()
|
||||
.get_private_account_commitment(holder_account_id)
|
||||
.context("Private holder commitment not found")?;
|
||||
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -8,7 +8,9 @@ use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
error::NssaError,
|
||||
program_methods::{AMM_ELF, AUTHENTICATED_TRANSFER_ELF, PINATA_ELF, TOKEN_ELF},
|
||||
program_methods::{
|
||||
AMM_ELF, ASSOCIATED_TOKEN_ACCOUNT_ELF, AUTHENTICATED_TRANSFER_ELF, PINATA_ELF, TOKEN_ELF,
|
||||
},
|
||||
};
|
||||
|
||||
/// Maximum number of cycles for a public execution.
|
||||
@ -105,6 +107,12 @@ impl Program {
|
||||
pub fn amm() -> Self {
|
||||
Self::new(AMM_ELF.to_vec()).expect("The AMM program must be a valid Risc0 program")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ata() -> Self {
|
||||
Self::new(ASSOCIATED_TOKEN_ACCOUNT_ELF.to_vec())
|
||||
.expect("The ATA program must be a valid Risc0 program")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Testnet only. Refactor to prevent compilation on mainnet.
|
||||
|
||||
@ -146,6 +146,7 @@ impl V03State {
|
||||
this.insert_program(Program::authenticated_transfer_program());
|
||||
this.insert_program(Program::token());
|
||||
this.insert_program(Program::amm());
|
||||
this.insert_program(Program::ata());
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
@ -13,5 +13,7 @@ token_core.workspace = true
|
||||
token_program.workspace = true
|
||||
amm_core.workspace = true
|
||||
amm_program.workspace = true
|
||||
ata_core.workspace = true
|
||||
ata_program.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
serde = { workspace = true, default-features = false }
|
||||
|
||||
65
program_methods/guest/src/bin/associated_token_account.rs
Normal file
65
program_methods/guest/src/bin/associated_token_account.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use ata_core::Instruction;
|
||||
use nssa_core::program::{ProgramInput, read_nssa_inputs, write_nssa_outputs_with_chained_call};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_nssa_inputs::<Instruction>();
|
||||
|
||||
let pre_states_clone = pre_states.clone();
|
||||
|
||||
let (post_states, chained_calls) = match instruction {
|
||||
Instruction::Create { ata_program_id } => {
|
||||
let [owner, token_definition, ata_account] = pre_states
|
||||
.try_into()
|
||||
.expect("Create instruction requires exactly three accounts");
|
||||
ata_program::create::create_associated_token_account(
|
||||
owner,
|
||||
token_definition,
|
||||
ata_account,
|
||||
ata_program_id,
|
||||
)
|
||||
}
|
||||
Instruction::Transfer {
|
||||
ata_program_id,
|
||||
amount,
|
||||
} => {
|
||||
let [owner, sender_ata, recipient] = pre_states
|
||||
.try_into()
|
||||
.expect("Transfer instruction requires exactly three accounts");
|
||||
ata_program::transfer::transfer_from_associated_token_account(
|
||||
owner,
|
||||
sender_ata,
|
||||
recipient,
|
||||
ata_program_id,
|
||||
amount,
|
||||
)
|
||||
}
|
||||
Instruction::Burn {
|
||||
ata_program_id,
|
||||
amount,
|
||||
} => {
|
||||
let [owner, holder_ata, token_definition] = pre_states
|
||||
.try_into()
|
||||
.expect("Burn instruction requires exactly three accounts");
|
||||
ata_program::burn::burn_from_associated_token_account(
|
||||
owner,
|
||||
holder_ata,
|
||||
token_definition,
|
||||
ata_program_id,
|
||||
amount,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
write_nssa_outputs_with_chained_call(
|
||||
instruction_words,
|
||||
pre_states_clone,
|
||||
post_states,
|
||||
chained_calls,
|
||||
);
|
||||
}
|
||||
10
programs/associated_token_account/Cargo.toml
Normal file
10
programs/associated_token_account/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "ata_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
nssa_core.workspace = true
|
||||
token_core.workspace = true
|
||||
ata_core.workspace = true
|
||||
10
programs/associated_token_account/core/Cargo.toml
Normal file
10
programs/associated_token_account/core/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "ata_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
nssa_core.workspace = true
|
||||
serde.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
82
programs/associated_token_account/core/src/lib.rs
Normal file
82
programs/associated_token_account/core/src/lib.rs
Normal file
@ -0,0 +1,82 @@
|
||||
pub use nssa_core::program::PdaSeed;
|
||||
use nssa_core::{
|
||||
account::{AccountId, AccountWithMetadata},
|
||||
program::ProgramId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Create the Associated Token Account for (owner, definition).
|
||||
/// Idempotent: no-op if the account already exists.
|
||||
///
|
||||
/// Required accounts (3):
|
||||
/// - Owner account
|
||||
/// - Token definition account
|
||||
/// - Associated token account (default/uninitialized, or already initialized)
|
||||
///
|
||||
/// `token_program_id` is derived from `token_definition.account.program_owner`.
|
||||
Create { ata_program_id: ProgramId },
|
||||
|
||||
/// Transfer tokens FROM owner's ATA to a recipient holding account.
|
||||
/// Uses PDA seeds to authorize the ATA in the chained Token::Transfer call.
|
||||
///
|
||||
/// Required accounts (3):
|
||||
/// - Owner account (authorized)
|
||||
/// - Sender ATA (owner's token holding)
|
||||
/// - Recipient token holding (any account; auto-created if default)
|
||||
///
|
||||
/// `token_program_id` is derived from `sender_ata.account.program_owner`.
|
||||
Transfer {
|
||||
ata_program_id: ProgramId,
|
||||
amount: u128,
|
||||
},
|
||||
|
||||
/// Burn tokens FROM owner's ATA.
|
||||
/// Uses PDA seeds to authorize the ATA in the chained Token::Burn call.
|
||||
///
|
||||
/// Required accounts (3):
|
||||
/// - Owner account (authorized)
|
||||
/// - Owner's ATA (the holding to burn from)
|
||||
/// - Token definition account
|
||||
///
|
||||
/// `token_program_id` is derived from `holder_ata.account.program_owner`.
|
||||
Burn {
|
||||
ata_program_id: ProgramId,
|
||||
amount: u128,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256};
|
||||
let mut bytes = [0u8; 64];
|
||||
bytes[0..32].copy_from_slice(&owner_id.to_bytes());
|
||||
bytes[32..64].copy_from_slice(&definition_id.to_bytes());
|
||||
PdaSeed::new(
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("Hash output must be exactly 32 bytes long"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_associated_token_account_id(ata_program_id: &ProgramId, seed: &PdaSeed) -> AccountId {
|
||||
AccountId::from((ata_program_id, seed))
|
||||
}
|
||||
|
||||
/// Verify the ATA's address matches `(ata_program_id, owner, definition)` and return
|
||||
/// the [`PdaSeed`] for use in chained calls.
|
||||
pub fn verify_ata_and_get_seed(
|
||||
ata_account: &AccountWithMetadata,
|
||||
owner: &AccountWithMetadata,
|
||||
definition_id: AccountId,
|
||||
ata_program_id: ProgramId,
|
||||
) -> PdaSeed {
|
||||
let seed = compute_ata_seed(owner.account_id, definition_id);
|
||||
let expected_id = get_associated_token_account_id(&ata_program_id, &seed);
|
||||
assert_eq!(
|
||||
ata_account.account_id, expected_id,
|
||||
"ATA account ID does not match expected derivation"
|
||||
);
|
||||
seed
|
||||
}
|
||||
39
programs/associated_token_account/src/burn.rs
Normal file
39
programs/associated_token_account/src/burn.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use nssa_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
pub fn burn_from_associated_token_account(
|
||||
owner: AccountWithMetadata,
|
||||
holder_ata: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
ata_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let token_program_id = holder_ata.account.program_owner;
|
||||
assert!(owner.is_authorized, "Owner authorization is missing");
|
||||
let definition_id = TokenHolding::try_from(&holder_ata.account.data)
|
||||
.expect("Holder ATA must hold a valid token")
|
||||
.definition_id();
|
||||
let seed =
|
||||
ata_core::verify_ata_and_get_seed(&holder_ata, &owner, definition_id, ata_program_id);
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(owner.account.clone()),
|
||||
AccountPostState::new(holder_ata.account.clone()),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
];
|
||||
let mut holder_ata_auth = holder_ata.clone();
|
||||
holder_ata_auth.is_authorized = true;
|
||||
|
||||
let chained_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![token_definition.clone(), holder_ata_auth],
|
||||
&token_core::Instruction::Burn {
|
||||
amount_to_burn: amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![seed]);
|
||||
(post_states, vec![chained_call])
|
||||
}
|
||||
44
programs/associated_token_account/src/create.rs
Normal file
44
programs/associated_token_account/src/create.rs
Normal file
@ -0,0 +1,44 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
|
||||
pub fn create_associated_token_account(
|
||||
owner: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
ata_account: AccountWithMetadata,
|
||||
ata_program_id: ProgramId,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
// No authorization check needed: create is idempotent, so anyone can call it safely.
|
||||
let token_program_id = token_definition.account.program_owner;
|
||||
ata_core::verify_ata_and_get_seed(
|
||||
&ata_account,
|
||||
&owner,
|
||||
token_definition.account_id,
|
||||
ata_program_id,
|
||||
);
|
||||
|
||||
// Idempotent: already initialized → no-op
|
||||
if ata_account.account != Account::default() {
|
||||
return (
|
||||
vec![
|
||||
AccountPostState::new_claimed_if_default(owner.account.clone()),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
AccountPostState::new(ata_account.account.clone()),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new_claimed_if_default(owner.account.clone()),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
AccountPostState::new(ata_account.account.clone()),
|
||||
];
|
||||
let chained_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![token_definition.clone(), ata_account.clone()],
|
||||
&token_core::Instruction::InitializeAccount,
|
||||
);
|
||||
(post_states, vec![chained_call])
|
||||
}
|
||||
10
programs/associated_token_account/src/lib.rs
Normal file
10
programs/associated_token_account/src/lib.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! The Associated Token Account Program implementation.
|
||||
|
||||
pub use ata_core as core;
|
||||
|
||||
pub mod burn;
|
||||
pub mod create;
|
||||
pub mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
153
programs/associated_token_account/src/tests.rs
Normal file
153
programs/associated_token_account/src/tests.rs
Normal file
@ -0,0 +1,153 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use nssa_core::account::{Account, AccountId, AccountWithMetadata, Data};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
const ATA_PROGRAM_ID: nssa_core::program::ProgramId = [1u32; 8];
|
||||
const TOKEN_PROGRAM_ID: nssa_core::program::ProgramId = [2u32; 8];
|
||||
|
||||
fn owner_id() -> AccountId {
|
||||
AccountId::new([0x01u8; 32])
|
||||
}
|
||||
|
||||
fn definition_id() -> AccountId {
|
||||
AccountId::new([0x02u8; 32])
|
||||
}
|
||||
|
||||
fn ata_id() -> AccountId {
|
||||
get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(owner_id(), definition_id()),
|
||||
)
|
||||
}
|
||||
|
||||
fn owner_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: true,
|
||||
account_id: owner_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: "TEST".to_string(),
|
||||
total_supply: 1000,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: nssa_core::account::Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: definition_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninitialized_ata_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: ata_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn initialized_ata_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: definition_id(),
|
||||
balance: 100,
|
||||
}),
|
||||
nonce: nssa_core::account::Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: ata_id(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_emits_chained_call_for_uninitialized_ata() {
|
||||
let (post_states, chained_calls) = crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
uninitialized_ata_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 3);
|
||||
assert_eq!(chained_calls.len(), 1);
|
||||
assert_eq!(chained_calls[0].program_id, TOKEN_PROGRAM_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_is_idempotent_for_initialized_ata() {
|
||||
let (post_states, chained_calls) = crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
initialized_ata_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 3);
|
||||
assert!(
|
||||
chained_calls.is_empty(),
|
||||
"Should emit no chained call for already-initialized ATA"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ATA account ID does not match expected derivation")]
|
||||
fn create_panics_on_wrong_ata_address() {
|
||||
let wrong_ata = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: AccountId::new([0xFFu8; 32]),
|
||||
};
|
||||
|
||||
crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
wrong_ata,
|
||||
ATA_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_is_deterministic() {
|
||||
let seed = compute_ata_seed(owner_id(), definition_id());
|
||||
let id1 = get_associated_token_account_id(&ATA_PROGRAM_ID, &seed);
|
||||
let id2 = get_associated_token_account_id(&ATA_PROGRAM_ID, &seed);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_differs_by_owner() {
|
||||
let other_owner = AccountId::new([0x99u8; 32]);
|
||||
let id1 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(owner_id(), definition_id()),
|
||||
);
|
||||
let id2 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(other_owner, definition_id()),
|
||||
);
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_differs_by_definition() {
|
||||
let other_def = AccountId::new([0x99u8; 32]);
|
||||
let id1 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(owner_id(), definition_id()),
|
||||
);
|
||||
let id2 =
|
||||
get_associated_token_account_id(&ATA_PROGRAM_ID, &compute_ata_seed(owner_id(), other_def));
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
39
programs/associated_token_account/src/transfer.rs
Normal file
39
programs/associated_token_account/src/transfer.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use nssa_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
pub fn transfer_from_associated_token_account(
|
||||
owner: AccountWithMetadata,
|
||||
sender_ata: AccountWithMetadata,
|
||||
recipient: AccountWithMetadata,
|
||||
ata_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let token_program_id = sender_ata.account.program_owner;
|
||||
assert!(owner.is_authorized, "Owner authorization is missing");
|
||||
let definition_id = TokenHolding::try_from(&sender_ata.account.data)
|
||||
.expect("Sender ATA must hold a valid token")
|
||||
.definition_id();
|
||||
let seed =
|
||||
ata_core::verify_ata_and_get_seed(&sender_ata, &owner, definition_id, ata_program_id);
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(owner.account.clone()),
|
||||
AccountPostState::new(sender_ata.account.clone()),
|
||||
AccountPostState::new(recipient.account.clone()),
|
||||
];
|
||||
let mut sender_ata_auth = sender_ata.clone();
|
||||
sender_ata_auth.is_authorized = true;
|
||||
|
||||
let chained_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![sender_ata_auth, recipient.clone()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![seed]);
|
||||
(post_states, vec![chained_call])
|
||||
}
|
||||
@ -15,6 +15,7 @@ key_protocol.workspace = true
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
token_core.workspace = true
|
||||
amm_core.workspace = true
|
||||
ata_core.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@ -14,8 +14,9 @@ use crate::{
|
||||
chain::ChainSubcommand,
|
||||
config::ConfigSubcommand,
|
||||
programs::{
|
||||
amm::AmmProgramAgnosticSubcommand, native_token_transfer::AuthTransferSubcommand,
|
||||
pinata::PinataProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand,
|
||||
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand,
|
||||
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
|
||||
token::TokenProgramAgnosticSubcommand,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -52,6 +53,9 @@ pub enum Command {
|
||||
/// AMM program interaction subcommand.
|
||||
#[command(subcommand)]
|
||||
AMM(AmmProgramAgnosticSubcommand),
|
||||
/// Associated Token Account program interaction subcommand.
|
||||
#[command(subcommand)]
|
||||
Ata(AtaSubcommand),
|
||||
/// Check the wallet can connect to the node and builtin local programs
|
||||
/// match the remote versions.
|
||||
CheckHealth,
|
||||
@ -158,6 +162,7 @@ pub async fn execute_subcommand(
|
||||
}
|
||||
Command::Token(token_subcommand) => token_subcommand.handle_subcommand(wallet_core).await?,
|
||||
Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?,
|
||||
Command::Ata(ata_subcommand) => ata_subcommand.handle_subcommand(wallet_core).await?,
|
||||
Command::Config(config_subcommand) => {
|
||||
config_subcommand.handle_subcommand(wallet_core).await?
|
||||
}
|
||||
|
||||
240
wallet/src/cli/programs/ata.rs
Normal file
240
wallet/src/cli/programs/ata.rs
Normal file
@ -0,0 +1,240 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use common::transaction::NSSATransaction;
|
||||
use nssa::{Account, AccountId, program::Program};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
use crate::{
|
||||
AccDecodeData::Decode,
|
||||
WalletCore,
|
||||
cli::{SubcommandReturnValue, WalletSubcommand},
|
||||
helperfunctions::{AccountPrivacyKind, parse_addr_with_privacy_prefix},
|
||||
program_facades::ata::Ata,
|
||||
};
|
||||
|
||||
/// Represents generic CLI subcommand for a wallet working with the ATA program.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum AtaSubcommand {
|
||||
/// Derive and print the Associated Token Account address (local only, no network).
|
||||
Address {
|
||||
/// Owner account - valid 32 byte base58 string (no privacy prefix).
|
||||
#[arg(long)]
|
||||
owner: String,
|
||||
/// Token definition account - valid 32 byte base58 string (no privacy prefix).
|
||||
#[arg(long)]
|
||||
token_definition: String,
|
||||
},
|
||||
/// Create (or idempotently no-op) the Associated Token Account.
|
||||
Create {
|
||||
/// Owner account - valid 32 byte base58 string with privacy prefix.
|
||||
#[arg(long)]
|
||||
owner: String,
|
||||
/// Token definition account - valid 32 byte base58 string WITHOUT privacy prefix.
|
||||
#[arg(long)]
|
||||
token_definition: String,
|
||||
},
|
||||
/// Send tokens from owner's ATA to a recipient token holding account.
|
||||
Send {
|
||||
/// Sender account - valid 32 byte base58 string with privacy prefix.
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
/// Token definition account - valid 32 byte base58 string WITHOUT privacy prefix.
|
||||
#[arg(long)]
|
||||
token_definition: String,
|
||||
/// Recipient account - valid 32 byte base58 string WITHOUT privacy prefix.
|
||||
#[arg(long)]
|
||||
to: String,
|
||||
#[arg(long)]
|
||||
amount: u128,
|
||||
},
|
||||
/// Burn tokens from holder's ATA.
|
||||
Burn {
|
||||
/// Holder account - valid 32 byte base58 string with privacy prefix.
|
||||
#[arg(long)]
|
||||
holder: String,
|
||||
/// Token definition account - valid 32 byte base58 string WITHOUT privacy prefix.
|
||||
#[arg(long)]
|
||||
token_definition: String,
|
||||
#[arg(long)]
|
||||
amount: u128,
|
||||
},
|
||||
/// List all ATAs for a given owner across multiple token definitions.
|
||||
List {
|
||||
/// Owner account - valid 32 byte base58 string (no privacy prefix).
|
||||
#[arg(long)]
|
||||
owner: String,
|
||||
/// Token definition accounts - valid 32 byte base58 strings (no privacy prefix).
|
||||
#[arg(long, num_args = 1..)]
|
||||
token_definition: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WalletSubcommand for AtaSubcommand {
|
||||
async fn handle_subcommand(
|
||||
self,
|
||||
wallet_core: &mut WalletCore,
|
||||
) -> Result<SubcommandReturnValue> {
|
||||
match self {
|
||||
Self::Address {
|
||||
owner,
|
||||
token_definition,
|
||||
} => {
|
||||
let owner_id: AccountId = owner.parse()?;
|
||||
let definition_id: AccountId = token_definition.parse()?;
|
||||
let ata_program_id = Program::ata().id();
|
||||
let ata_id = ata_core::get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&ata_core::compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
println!("{ata_id}");
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
Self::Create {
|
||||
owner,
|
||||
token_definition,
|
||||
} => {
|
||||
let (owner_str, owner_privacy) = parse_addr_with_privacy_prefix(&owner)?;
|
||||
let owner_id: AccountId = owner_str.parse()?;
|
||||
let definition_id: AccountId = token_definition.parse()?;
|
||||
|
||||
match owner_privacy {
|
||||
AccountPrivacyKind::Public => {
|
||||
Ata(wallet_core)
|
||||
.send_create(owner_id, definition_id)
|
||||
.await?;
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
AccountPrivacyKind::Private => {
|
||||
let (tx_hash, secret) = Ata(wallet_core)
|
||||
.send_create_private_owner(owner_id, definition_id)
|
||||
.await?;
|
||||
|
||||
println!("Transaction hash is {tx_hash}");
|
||||
|
||||
let tx = wallet_core.poll_native_token_transfer(tx_hash).await?;
|
||||
if let NSSATransaction::PrivacyPreserving(tx) = tx {
|
||||
wallet_core.decode_insert_privacy_preserving_transaction_results(
|
||||
&tx,
|
||||
&[Decode(secret, owner_id)],
|
||||
)?;
|
||||
}
|
||||
|
||||
wallet_core.store_persistent_data().await?;
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Send {
|
||||
from,
|
||||
token_definition,
|
||||
to,
|
||||
amount,
|
||||
} => {
|
||||
let (from_str, from_privacy) = parse_addr_with_privacy_prefix(&from)?;
|
||||
let from_id: AccountId = from_str.parse()?;
|
||||
let definition_id: AccountId = token_definition.parse()?;
|
||||
let to_id: AccountId = to.parse()?;
|
||||
|
||||
match from_privacy {
|
||||
AccountPrivacyKind::Public => {
|
||||
Ata(wallet_core)
|
||||
.send_transfer(from_id, definition_id, to_id, amount)
|
||||
.await?;
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
AccountPrivacyKind::Private => {
|
||||
let (tx_hash, secret) = Ata(wallet_core)
|
||||
.send_transfer_private_owner(from_id, definition_id, to_id, amount)
|
||||
.await?;
|
||||
|
||||
println!("Transaction hash is {tx_hash}");
|
||||
|
||||
let tx = wallet_core.poll_native_token_transfer(tx_hash).await?;
|
||||
if let NSSATransaction::PrivacyPreserving(tx) = tx {
|
||||
wallet_core.decode_insert_privacy_preserving_transaction_results(
|
||||
&tx,
|
||||
&[Decode(secret, from_id)],
|
||||
)?;
|
||||
}
|
||||
|
||||
wallet_core.store_persistent_data().await?;
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Burn {
|
||||
holder,
|
||||
token_definition,
|
||||
amount,
|
||||
} => {
|
||||
let (holder_str, holder_privacy) = parse_addr_with_privacy_prefix(&holder)?;
|
||||
let holder_id: AccountId = holder_str.parse()?;
|
||||
let definition_id: AccountId = token_definition.parse()?;
|
||||
|
||||
match holder_privacy {
|
||||
AccountPrivacyKind::Public => {
|
||||
Ata(wallet_core)
|
||||
.send_burn(holder_id, definition_id, amount)
|
||||
.await?;
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
AccountPrivacyKind::Private => {
|
||||
let (tx_hash, secret) = Ata(wallet_core)
|
||||
.send_burn_private_owner(holder_id, definition_id, amount)
|
||||
.await?;
|
||||
|
||||
println!("Transaction hash is {tx_hash}");
|
||||
|
||||
let tx = wallet_core.poll_native_token_transfer(tx_hash).await?;
|
||||
if let NSSATransaction::PrivacyPreserving(tx) = tx {
|
||||
wallet_core.decode_insert_privacy_preserving_transaction_results(
|
||||
&tx,
|
||||
&[Decode(secret, holder_id)],
|
||||
)?;
|
||||
}
|
||||
|
||||
wallet_core.store_persistent_data().await?;
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::List {
|
||||
owner,
|
||||
token_definition,
|
||||
} => {
|
||||
let owner_id: AccountId = owner.parse()?;
|
||||
let ata_program_id = Program::ata().id();
|
||||
|
||||
for def in &token_definition {
|
||||
let definition_id: AccountId = def.parse()?;
|
||||
let ata_id = ata_core::get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&ata_core::compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
let account = wallet_core.get_account_public(ata_id).await?;
|
||||
|
||||
if account == Account::default() {
|
||||
println!("No ATA for definition {definition_id}");
|
||||
} else {
|
||||
let holding = TokenHolding::try_from(&account.data)?;
|
||||
match holding {
|
||||
TokenHolding::Fungible { balance, .. } => {
|
||||
println!(
|
||||
"ATA {ata_id} (definition {definition_id}): balance {balance}"
|
||||
);
|
||||
}
|
||||
TokenHolding::NftMaster { .. }
|
||||
| TokenHolding::NftPrintedCopy { .. } => {
|
||||
println!(
|
||||
"ATA {ata_id} (definition {definition_id}): unsupported token type"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod amm;
|
||||
pub mod ata;
|
||||
pub mod native_token_transfer;
|
||||
pub mod pinata;
|
||||
pub mod token;
|
||||
|
||||
@ -64,6 +64,8 @@ pub enum ExecutionFailureKind {
|
||||
InsufficientFundsError,
|
||||
#[error("Account {0} data is invalid")]
|
||||
AccountDataError(AccountId),
|
||||
#[error("Failed to build transaction: {0}")]
|
||||
TransactionBuildError(#[from] nssa::error::NssaError),
|
||||
}
|
||||
|
||||
#[expect(clippy::partial_pub_fields, reason = "TODO: make all fields private")]
|
||||
|
||||
280
wallet/src/program_facades/ata.rs
Normal file
280
wallet/src/program_facades/ata.rs
Normal file
@ -0,0 +1,280 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use common::{HashType, transaction::NSSATransaction};
|
||||
use nssa::{
|
||||
AccountId, privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
|
||||
};
|
||||
use nssa_core::SharedSecretKey;
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
|
||||
use crate::{ExecutionFailureKind, PrivacyPreservingAccount, WalletCore};
|
||||
|
||||
pub struct Ata<'wallet>(pub &'wallet WalletCore);
|
||||
|
||||
impl Ata<'_> {
|
||||
pub async fn send_create(
|
||||
&self,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
) -> Result<HashType, ExecutionFailureKind> {
|
||||
let program = Program::ata();
|
||||
let ata_program_id = program.id();
|
||||
let ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
|
||||
let account_ids = vec![owner_id, definition_id, ata_id];
|
||||
|
||||
let nonces = self
|
||||
.0
|
||||
.get_accounts_nonces(vec![owner_id])
|
||||
.await
|
||||
.map_err(ExecutionFailureKind::SequencerError)?;
|
||||
|
||||
let Some(signing_key) = self
|
||||
.0
|
||||
.storage
|
||||
.user_data
|
||||
.get_pub_account_signing_key(owner_id)
|
||||
else {
|
||||
return Err(ExecutionFailureKind::KeyNotFoundError);
|
||||
};
|
||||
|
||||
let instruction = ata_core::Instruction::Create { ata_program_id };
|
||||
|
||||
let message = nssa::public_transaction::Message::try_new(
|
||||
program.id(),
|
||||
account_ids,
|
||||
nonces,
|
||||
instruction,
|
||||
)?;
|
||||
|
||||
let witness_set =
|
||||
nssa::public_transaction::WitnessSet::for_message(&message, &[signing_key]);
|
||||
|
||||
let tx = nssa::PublicTransaction::new(message, witness_set);
|
||||
|
||||
Ok(self
|
||||
.0
|
||||
.sequencer_client
|
||||
.send_transaction(NSSATransaction::Public(tx))
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn send_transfer(
|
||||
&self,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
recipient_id: AccountId,
|
||||
amount: u128,
|
||||
) -> Result<HashType, ExecutionFailureKind> {
|
||||
let program = Program::ata();
|
||||
let ata_program_id = program.id();
|
||||
let sender_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
|
||||
let account_ids = vec![owner_id, sender_ata_id, recipient_id];
|
||||
|
||||
let nonces = self
|
||||
.0
|
||||
.get_accounts_nonces(vec![owner_id])
|
||||
.await
|
||||
.map_err(ExecutionFailureKind::SequencerError)?;
|
||||
|
||||
let Some(signing_key) = self
|
||||
.0
|
||||
.storage
|
||||
.user_data
|
||||
.get_pub_account_signing_key(owner_id)
|
||||
else {
|
||||
return Err(ExecutionFailureKind::KeyNotFoundError);
|
||||
};
|
||||
|
||||
let instruction = ata_core::Instruction::Transfer {
|
||||
ata_program_id,
|
||||
amount,
|
||||
};
|
||||
|
||||
let message = nssa::public_transaction::Message::try_new(
|
||||
program.id(),
|
||||
account_ids,
|
||||
nonces,
|
||||
instruction,
|
||||
)?;
|
||||
|
||||
let witness_set =
|
||||
nssa::public_transaction::WitnessSet::for_message(&message, &[signing_key]);
|
||||
|
||||
let tx = nssa::PublicTransaction::new(message, witness_set);
|
||||
|
||||
Ok(self
|
||||
.0
|
||||
.sequencer_client
|
||||
.send_transaction(NSSATransaction::Public(tx))
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn send_burn(
|
||||
&self,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
amount: u128,
|
||||
) -> Result<HashType, ExecutionFailureKind> {
|
||||
let program = Program::ata();
|
||||
let ata_program_id = program.id();
|
||||
let holder_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
|
||||
let account_ids = vec![owner_id, holder_ata_id, definition_id];
|
||||
|
||||
let nonces = self
|
||||
.0
|
||||
.get_accounts_nonces(vec![owner_id])
|
||||
.await
|
||||
.map_err(ExecutionFailureKind::SequencerError)?;
|
||||
|
||||
let Some(signing_key) = self
|
||||
.0
|
||||
.storage
|
||||
.user_data
|
||||
.get_pub_account_signing_key(owner_id)
|
||||
else {
|
||||
return Err(ExecutionFailureKind::KeyNotFoundError);
|
||||
};
|
||||
|
||||
let instruction = ata_core::Instruction::Burn {
|
||||
ata_program_id,
|
||||
amount,
|
||||
};
|
||||
|
||||
let message = nssa::public_transaction::Message::try_new(
|
||||
program.id(),
|
||||
account_ids,
|
||||
nonces,
|
||||
instruction,
|
||||
)?;
|
||||
|
||||
let witness_set =
|
||||
nssa::public_transaction::WitnessSet::for_message(&message, &[signing_key]);
|
||||
|
||||
let tx = nssa::PublicTransaction::new(message, witness_set);
|
||||
|
||||
Ok(self
|
||||
.0
|
||||
.sequencer_client
|
||||
.send_transaction(NSSATransaction::Public(tx))
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn send_create_private_owner(
|
||||
&self,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> {
|
||||
let ata_program_id = Program::ata().id();
|
||||
let ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
|
||||
let instruction = ata_core::Instruction::Create { ata_program_id };
|
||||
let instruction_data =
|
||||
Program::serialize_instruction(instruction).expect("Instruction should serialize");
|
||||
|
||||
let accounts = vec![
|
||||
PrivacyPreservingAccount::PrivateOwned(owner_id),
|
||||
PrivacyPreservingAccount::Public(definition_id),
|
||||
PrivacyPreservingAccount::Public(ata_id),
|
||||
];
|
||||
|
||||
self.0
|
||||
.send_privacy_preserving_tx(accounts, instruction_data, &ata_with_token_dependency())
|
||||
.await
|
||||
.map(|(hash, mut secrets)| {
|
||||
let secret = secrets.pop().expect("expected owner's secret");
|
||||
(hash, secret)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_transfer_private_owner(
|
||||
&self,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
recipient_id: AccountId,
|
||||
amount: u128,
|
||||
) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> {
|
||||
let ata_program_id = Program::ata().id();
|
||||
let sender_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
|
||||
let instruction = ata_core::Instruction::Transfer {
|
||||
ata_program_id,
|
||||
amount,
|
||||
};
|
||||
let instruction_data =
|
||||
Program::serialize_instruction(instruction).expect("Instruction should serialize");
|
||||
|
||||
let accounts = vec![
|
||||
PrivacyPreservingAccount::PrivateOwned(owner_id),
|
||||
PrivacyPreservingAccount::Public(sender_ata_id),
|
||||
PrivacyPreservingAccount::Public(recipient_id),
|
||||
];
|
||||
|
||||
self.0
|
||||
.send_privacy_preserving_tx(accounts, instruction_data, &ata_with_token_dependency())
|
||||
.await
|
||||
.map(|(hash, mut secrets)| {
|
||||
let secret = secrets.pop().expect("expected owner's secret");
|
||||
(hash, secret)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_burn_private_owner(
|
||||
&self,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
amount: u128,
|
||||
) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> {
|
||||
let ata_program_id = Program::ata().id();
|
||||
let holder_ata_id = get_associated_token_account_id(
|
||||
&ata_program_id,
|
||||
&compute_ata_seed(owner_id, definition_id),
|
||||
);
|
||||
|
||||
let instruction = ata_core::Instruction::Burn {
|
||||
ata_program_id,
|
||||
amount,
|
||||
};
|
||||
let instruction_data =
|
||||
Program::serialize_instruction(instruction).expect("Instruction should serialize");
|
||||
|
||||
let accounts = vec![
|
||||
PrivacyPreservingAccount::PrivateOwned(owner_id),
|
||||
PrivacyPreservingAccount::Public(holder_ata_id),
|
||||
PrivacyPreservingAccount::Public(definition_id),
|
||||
];
|
||||
|
||||
self.0
|
||||
.send_privacy_preserving_tx(accounts, instruction_data, &ata_with_token_dependency())
|
||||
.await
|
||||
.map(|(hash, mut secrets)| {
|
||||
let secret = secrets.pop().expect("expected owner's secret");
|
||||
(hash, secret)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn ata_with_token_dependency() -> ProgramWithDependencies {
|
||||
let token = Program::token();
|
||||
let mut deps = HashMap::new();
|
||||
deps.insert(token.id(), token);
|
||||
ProgramWithDependencies::new(Program::ata(), deps)
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
//! on-chain programs.
|
||||
|
||||
pub mod amm;
|
||||
pub mod ata;
|
||||
pub mod native_token_transfer;
|
||||
pub mod pinata;
|
||||
pub mod token;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user