Skip to content

Instantly share code, notes, and snippets.

@ekkis
Last active February 17, 2024 01:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ekkis/6706bdb4788296556ab44eaaed28f148 to your computer and use it in GitHub Desktop.
Save ekkis/6706bdb4788296556ab44eaaed28f148 to your computer and use it in GitHub Desktop.

Namada: My First Transaction

Being able to send blockchain tokens in a truly anonymous manner is of critical importance both to the business world and to our individual freedoms

image

The problem of true anonymity was first solved by z-Cash via the zk-SNARK protocol, a first implementation of zero-knowledge proofs in cryptography. However, this technology did not at first benefit any other player in the blockchain space, and the zcash token itself has received relatively little attention

More recently, the open-source project Namada, built on the Cosmos ecosystem with the team from ZCash, has created an implementation of ZKPs, allowing token users to send tokens anonymously across chains¹ within the platform, via the use of shielded transactions

What You Can Expect

In this document I will walk you through setting up the required infrastructure to execute transactions on a testnet, and share (with explanations) the most minimal bits of code needed to perform a shielded transaction By the end of this article you should have working code you can use as the seed for building something greater

Basic Infrastructure

To follow this recipe you must have the following components:

  • Apple's OS/X operating system. This recipe is known to work on Ventura 13.6.1 so if your OS version differs, you mileage may vary
  • the Homebrew package manager (see link for install instructions)
  • Docker Desktop - a virtual machine host that makes our process significantly easier. Whilst not strictly necessary (you can compile a node from code or even get binaries), Docker allows us to run a testnet hassle-free and the process works flawlessly
  • You'll also need git, which you can install by installing the command-line tools for XCode, or can be installed like this:
brew install git

Running a Local Chain (in Docker containers)

The first thing we need to do is run a local chain we can connect to. For the sake of this document, we'll run Campfire, one of a number of testnets available, which runs as a collection of Docker containers, thanks to the Namada Selfhost project. First let's grab it from Github as shown below:

git clone https://github.com/0x4r45h/namada-selfhost.git
cd namada-selfhost

The project comes with a sample configuration file which must be renamed for use. Please note that for this recipe to work, the version of Namada the orchestrator runs and this recipe is known to work with is set as shown below:

ver="v0.31.0"
sed -e "s/^NAMADA_TAG=.*/NAMADA_TAG=$ver/" .env.sample > .env

We now let the orchestrator run the validator nodes for us:

docker compose pull   # loads all the images (may take a little while)
docker compose up -d  # runs the node
cd ..                 # don't forget this!

and, if you look in the Docker Desktop app will look something like:

image

The chain will take a little while to come up (but it's far faster than running a public network which make take a much as 20 hours to sync) and you can check its status with the command below. When the result is false, you're ready to go!

function namada_status() {
    curl -s http://127.0.0.1:26657/status |jq .result
}
namada_status |jq -r .sync_info.catching_up

If you don't have jq installedd, Brew can do it for you:

brew install jq

Installing Local Binaries

For development purposes we cannot work inside the containers, so having binaries locally installed makes sense. Let's grab these from Github² and put them into a directory in our path (they must be the same version as that run by the orchestrator):

loc="https://github.com/anoma/namada/releases/download/$ver/namada-$ver-Darwin-x86_64.tar.gz"
curl -L $loc |tar xzvf -
cp $(echo $loc |sed 's/.*\///; s/\.tar\.gz$//')/namada* /usr/local/bin

now make sure you've got the right version:

namada --version

Joining the Chain

We can now connect our binaries to the local validators by joining the local chain. The chain id is handily provided to us by a service running on the first validator on port 8123 (this port number is internal to the container but it's mapped to a local port by Docker, which the code below looks up)

# fetch chain-id

node=namada-selfhost-namada-1-1
port=$(docker ps -f name=$node --format 'json {{.Ports}}' |perl -ne '/:(\d+)->8123/; print $1')
cfg_srv=http://127.0.0.1:$port/
chain_id=$(curl -sL $cfg_srv |perl -ne 'print $1 if /href="(.*?)\.tar.gz/')

# join the network

export NAMADA_NETWORK_CONFIGS_SERVER=$cfg_srv
namada client utils join-network --chain-id $chain_id

The above will create a directory at ~/Library/Application Support/Namada containing genesis files and connection information for your chain. In my case, the directory is called local.73532805d0f0897a687825d1

Setting Up and Funding the Wallets

We create two keypairs or accounts in our wallet: donor (which we'll send from), and charity (that we'll send to):

alias wallet-gen="namada wallet gen --unsafe-dont-encrypt --alias"
wallet-gen donor
wallet gen charity

# you can show existing wallets with
namada wallet list

Please note that because these keys are for development purposes, security is not a concern. The --unsafe-dont-encrypt switch saves you from having to enter and remember an encryption password. Additionally, the commands above will generate BIP39 phrases that you would ordinarily save somewhere, but as you'll never need to restore these keys, saving them in unnecessary

Next we need some coin. Thankfully the validators come preloaded with NAM tokens we can use for our test. The code below will fund the donor account with 10 tokens:

# get the public address for the donor account

donor=$(namada wallet list --addr |perl -ne 'print $1 if /"donor":.*(tnam.*)/')

# transfer 10 tokens

w=namada-1-wallet
transfer="docker exec -it $node /usr/local/bin/namada client transfer"
$transfer --source $w --target $donor --token NAM --amount 10

...which will produce output like this:

Transaction added to mempool. Wrapper transaction hash: D51D8B1310A62E31522BE303653A3DC025765D17F55AC8EEDFA0446295315015 Inner transaction hash: FFE3152914F6A2067C3DEF7CC1915F5D68B53D752757B81F9C16F9189EE35A4E Wrapper transaction accepted at height 7782. Used 22 gas. Waiting for inner transaction result... Transaction was successfully applied at height 7783. Used 7728 gas.

And now check your balance:

namada client balance --owner donor

However, before the donor account can send transactions, its public key must be "revealed". That is because when the account signs a transaction, the verifiers need to have the public key corresponding to the sending address and that public key cannot be generated from the address itself, therefore, a transaction has to be created that links the implicit address to the public key. That is done like this:

donor_pk=$(namadaw find --alias=donor |perl -ne 'print $1 if /Public key:\s+(.*)/')
namada reveal-pk --public-key=$donor_pk

...which will generate output similar to this:

Submitting a tx to reveal the public key for address tnam1qpqqnuh6yh624nut0sx2py4zwk7u487yks74fsg5... Transaction added to mempool. Wrapper transaction hash: 1648A4619A1A8CF080BF1FDEBC7CF7911CC7D0F43D7F48EB04F5B209AC6F7D53 Inner transaction hash: 397B5200540533C560573CA454C43DA5F12046CEDAB8DF5DA04E1E81C5C5843C Wrapper transaction accepted at height 7313. Used 18 gas. Waiting for inner transaction result... Transaction was successfully applied at height 7314. Used 6978 gas.


Now Let's Build Code!

Functionality on Namada is built on Rust, so you'll need the compiler installed. If you don't already have it, install it like this:

brew install rust

First, let's create a new project (I've chosen to call it namada-poc but you can call it what you like):

cargo new namada-poc && cd $_

Now we need to include a few "crates" (libraries of functionality) that we'll need in our programme. We do this by editing the Cargo.toml file and making sure the [dependencies] section contains the following:

[dependencies]
tokio = { version = "1.0", features = ["full"] }
dotenvy = "0.15.7"

Let's now edit the src/main.rs making it look like this:

use dotenvy::dotenv;

#[tokio::main]
async fn main() {
    dotenv().ok(); // read environment file
}

A word of explanation: the main() function in Rust programmes cannot normally be declared as asynchronous, which means it's not able to call functions that return futures (as promises are called in Rust). We fix that via the use of a runtime engine, which is what the decorator #[tokio::main] does

Also, to facilitate state we'll keep needed information in an environment file. The dotenvy crate provides functionality for reading such files. Naturally then, we'll also need to create a .env file. We do this as follows:

cat <<+ > .env
RPC=http://127.0.0.1:26657
CHAIN_ID=$chain_id
TOKEN=nam
SOURCE=donor
TARGET=charity
AMOUNT=1000000
+

Please note that the values for CHAIN_ID was computed earlier and SOURCE and TARGET refer to the 2 keypairs we created earlier, whilst TOKEN is the name of the token we funded donor with.

The environment file can now be be loaded into the programme but to use it we need one more step: create a src/config.rs with a struct that can hold that data:

#[derive(clap::Parser, Debug)]
pub struct AppConfig {
    #[clap(long, env)]
    pub rpc: String,

    #[clap(long, env)]
    pub chain_id: String,
    
    #[clap(long, env)]
    pub token: String,
    
    #[clap(long, env)]
    pub source: String,
    
    #[clap(long, env)]
    pub target: String,
    
    #[clap(long, env)]
    pub amount: u64,
}

…make it available for use:

echo "pub mod config;" > src/lib.rs

…add the crate below to the Cargo.toml which facilitates parsing:

clap = { version = "4.4.2", features = ["derive", "env"] }

…and now we can load these values into a config variable. Amend the src/main.rs file as follows:

// add these imports to the top of the file

use clap::Parser;
use std::{sync::Arc};
use namada_poc::{config::AppConfig};

// and call within main()

let config = Arc::new(AppConfig::parse());

Try running cargo run for it to compile!

Connecting to the validators

Next we'll try connecting to our local chain. Let's include the Namada SDK and the Tendermint RPC library in the Cargo.toml file:

namada_sdk = { git = "https://github.com/anoma/namada", tag = "v0.0.0", default-features = false, features = ["tendermint-rpc", "std", "async-client", "async-send", "download-params", "rand"] }
tendermint-rpc = { version = "0.34.0", features = ["http-client"] }

and import and use it in the code:

// top of the file

use std::str::FromStr;
use tendermint_rpc::{HttpClient, Url};

// call within main()

let url = Url::from_str(&config.rpc).expect("invalid RPC address");
let http_client = HttpClient::new(url).unwrap();

The first line converts the string we extracted from the environment file into a proper url object, issuing an error if the string is not validly formatted, and uses that url to create an HTTP client. This client will later be given to our SDK object to call on

Next we load the wallet we created using the CLI:

// top of the file

use namada_sdk::wallet::fs::FsWalletUtils;

// call within main()

let basedir = "Library/Application Support/Namada";
let basedir = format!("{}/{}/{}", std::env::var("HOME").unwrap(), basedir, &config.chain_id);

let mut wallet = FsWalletUtils::new(basedir.into());
wallet.load().expect("Failed to load wallet");

...and we create a shielded context for our transactions:

// top of the file

use namada_sdk::masp::fs::FsShieldedUtils;

// call within main()

let shielded_ctx = FsShieldedUtils::new("masp".into());

Next we need to get the addresses for the keypairs listed in the environment file. We use a little function to find them in the wallet:

// top of the file

use namada_sdk::core::types::{
  address::Address
};

// just before main()

fn get_address(w: &Wallet<FsWalletUtils>, val: &String) -> Address {
	let s = w.find_address(val).map(|addr| addr.to_string()).unwrap();
	Address::decode(s).unwrap()
}

...and we grab the addresses for the NAM token, charity account:

// call within main()

let token = get_address(&wallet, &config.token);
let target = get_address(&wallet, &config.target);

For the donor account we need the spending key (since we need to sign the transaction) and its address:

// within main()

let sk = wallet.find_secret_key(config.source.clone(), None)
	.expect("Unable to find key");
let source = Address::from(&sk.ref_to());

We can now create an SDK object to use in building our transaction:

// top of the file

use namada_sdk::{
    NamadaImpl, io::NullIo,
    args::TxBuilder // facilitates the .chain_id() call
};

// call in main()

let sdk = NamadaImpl::new(http_client, wallet, shielded_ctx, NullIo)
  .await
  .expect("unable to initialize Namada context")
  .chain_id(ChainId::from_str(&config.chain_id).unwrap());

The above hands us an object to access our SDK that is connected to the chain specified in the environment file, with access to our local wallet and a context for shielding transactions

Having handed the wallet to the SDK object, we no longer need it so we drop it:

drop(sdk.wallet.write().await);

We now denominate the amount to transfer, which includes proper designation of the token to transfer, and the amount involved. Please note that the amount for the test is expressed in the environment file in cents (NAM tokens are divisible to 6 digits), therefore to transfer 1 NAM token we must indicate to the denominator function 1 x 10^6:

// top of the file

use namada_sdk::rpc;

// call in main()

let amt = rpc::denominate_amount(
    sdk.client(),
    sdk.io(),
    &token,
    config.amount.into(),
).await;

…which we can now build a transaction for:

// top of the file

use namada_sdk::{
    args::InputAmount,
    core::types::masp::{
        TransferSource, TransferTarget
    }
};

// call in main()

let mut transfer_tx_builder = sdk.new_transfer( 
    TransferSource::Address(source),
    TransferTarget::Address(target.clone()),
    token.clone(),
    InputAmount::Unvalidated(amt),
);

and (nicely!) we can add arbitrary text data to the transaction, which means we could include information relevant to a payment (like a delivery address) without revealing that information to the world but only to the recipient:

let memo = String::from("{\"deliver-to\": \"101 Main Street, Lalaland, CA 91002\"}");
transfer_tx_builder.tx.memo = Some(memo.as_bytes().to_vec());

and now we can, finally, build the transaction, sign it and broadcast it to the network:

// top of file

use namada_sdk::signing::default_sign;

// call in main()

let (mut transfer_tx, signing_data, _epoch) = transfer_tx_builder
    .build(&sdk)
    .await
    .expect("unable to build transfer");
    
sdk.sign(
    &mut transfer_tx,
    &transfer_tx_builder.tx,
    signing_data,
    default_sign,
    (),
)
.await
.expect("unable to sign reveal pk tx");

Now we submit the transaction to the network and process the response, printing the status of the transaction and its hash to the console:

// top of file

use namada_sdk::tendermint::abci::Code;

// call in main()

let process_tx_response = sdk
  .submit(transfer_tx, &transfer_tx_builder.tx)
  .await;

let (sent, tx_hash) = if let Ok(response) = process_tx_response {
        match response {
            namada_sdk::tx::ProcessTxResponse::Applied(r) => (r.code.eq(&ResultCode::Ok), Some(r.hash)),
            namada_sdk::tx::ProcessTxResponse::Broadcast(r) => {
                (r.code.eq(&Code::Ok), Some(r.hash.to_string()))
            }
            _ => (false, None),
        }
    } else {
        (false, None)
    };

// display the transaction hash

print!("sent: {}", sent);
print!("tx: {}", tx_hash.unwrap());

The above should compile and run, performing a test transaction


Upgrading your infrastructure

As the network upgrades, you can keep up easily thanks to the namada-selfhost project. To upgrade should be as easy as (from the folder where you cloned the project):

git pull               # perform in the folder where the project was cloned
docker compose down -v # the -v removes old volumes
docker compose up -d   # restart services

However, don't forget to also update your CLI binaries and the SDK!

Support

The official documentation for Namada is available for anyone to read but to learn more and build interesting things, having access to the Namada community is invaluable. Fortunately, you'll find an active community on Discord, where you'll also find me lurking (as @ekkis). I'm also available on X/Telegram (same username)

Additionally, the entire code base for this article may be found on my Github repo poc-namada-tx, which you can grab like this:

git clone https://github.com/ekkis/poc-namada-tx.git

Conclusion

Software construction is never easy and certainly the complexity of building on decentralised platforms is dizzying. However, the choice of Rust as a language (and the richness of structures it provides) and Cosmos (a well architected ecosystem) help greatly in achieving functionality that wouldn't have been possible even a few years ago

If you are a developer, it's a great time to be involved and certainly the crypto world is the cutting edge. I look forward to seeing zero-knowledge technology permeate the blockchain ecosystem in the same way that the EFF's HTTPS Everywhere³ campaign did the internet


Footnotes

  1. Cross-chain token transfers are accomplished using the Axelar infrastructure, an IBC (inter-blockchain communications) protocol implementation for Cosmos. This allows your tokens to travel across to any Cosmos blockchain, but even to Ethereum and other chains via bridges
  2. cf. https://www.eff.org/https-everywhere
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment