Private Transactions

As discussed previously, we need to craft a ZTransaction struct that represents a shielded transaction. This is so that we can invoke the transact method on-chain and execute the private transaction.

function transact(ZTransaction memory ztx) external;

We also saw previously how to initialize an instance (variable zkfi ) of SDK.

Creating Transactions

The SDK exposes easy API for developers to specify what transaction they want to send. We will create a transaction using TransactionRequest and TransactionOptions object and pass it to createTransaction(...) method of our SDK instance zkfi.

type TransactionRequest = {
  type: TransactionType;
  assetIds: number[];
  values: bigint[];
  feeAssetId: number;
  to: string;
  payload?: string;
};

export type TransactionOptions = {
  revokerId: number;
  paymaster?: Hex;
  viaBundler?: boolean;
};

Since, initially we have no funds in our shielded account, we'd like to deposit some assets into it. Let's say, we'd like to deposit 2 WETH . To achieve this, we create a request with the following parameters:

import { parseEther } from 'viem';
import { TransactionRequest, TransactionOptions, TransactionType } from '@zkfi-tech/shared-types';

const req: TransactionRequest = {
  type: TransactionType.DEPOSIT,
  assetIds: [65537],
  values: [parseEther('2')],
  feeAssetId: 0,
  to: 'bob.eth',
}

const options: TransactionOptions = {
  revokerId: 0
}

So what did we do? Let's break it down:

  1. TransactionRequest

  • We set type to TransactionType.DEPOSIT since we want to do a deposit from our public wallet to shielded account.

  • We set assetIds to include only one asset of id 65537 (0x010001 in hex) which is the asset id for WETH in Labyrinth. We only want to deposit one asset i.e WETH. You can get a list of all supported assets from contractService::getAssets().

  • Let values to include only one value which is 2 ether . Since it is at the same index as WETH in assetIds, it is the value of WETH we want to deposit.

  • We set feeAssetId to 0. 0 is a dummy/placeholder asset id and does not represent any token or asset in reality. We set it to 0 because in a DEPOSIT transaction, we are not going to pay any fee, from any of the assets mentioned in assetIds. This is because we will be paying gas fees ourselves from our wallets.

  • We set to to the address, shielded address, or ENS of the target. In this case, it is the recipient of the deposit i.e. bob.eth. We assume that the address associated with bob.eth is already registered with Labyrinth protocol so that it can be ultimately resolved to a shielded address where the funds will be sent.

  1. TransactionOptions: - revokerId: The id of the revoker chosen to keep this transaction compliant.

Now we create the Transaction:

import { Transaction } from '@zkfi-tech/transaction';
import { TransactionRequest, TransactionOptions, TransactionType } from '@zkfi-tech/shared-types';

const req: TransactionRequest = {...};
const options: TransactionOptions = {...};

const tx: Transaction = await zkfi.createTransaction(req, options);

Now to authorize this transaction if it needs to be signed. For this, we pass the created transaction instance tx to zkfi.signTransaction(...) the method.

const signedTx: Transaction = await zkfi.signTransaction(tx);

We've created the transaction finally! But wait. it is not yet ready to be sent on-chain! Remember we need a shielded transaction ZTransaction for this. To create a shielded transaction we need to hide sensitive values and prove our signed transaction.

Again, zkfiinstance provides a simple proveTransaction(...)method to generate a ZK-SNARK proof proving the validity of the transaction tx and yield an instance of the ZTransaction class.

import { ZTransaction } from '@zkfi-tech/zk-prover';

const ztx: ZTransaction = await zkfi.proveTransaction(tx);

Finally, we have a private and fully compliant transaction represented by the ZTransaction instance - ztx . If you want to send this transaction on-chain, you can simply convert it to proper input to the contract's transact by calling ztx.toSolidityInput().

Congrats!

Creating a private asset transfer

Now that we have some funds in our shielded account, let's use it! Let's transfer 1 WETH privately to the alice.eth (again, assuming that the public address associated with alice.eth is already registered).

First, we create a TRANSFER request.

import { parseEther } from 'viem';
import { TransactionRequest, TransactionOptions, TransactionType } from '@zkfi-tech/shared-types';

const req: TransactionRequest = {
  type: TransactionType.TRANSFER,
  assetIds: [65537],
  values: [parseEther('1')],
  feeAssetId: 65537,
  to: 'alice.eth',
}

const options: TransactionOptions = {
  revokerId: 0,
  paymaster?: '0xE45c40643af3aa4146E1B1C95051c23f7439ed75',
  viaBundler?: true
}

We specify the same kind of fields in our request, except this time we specify a feeAssetId of WETH . We specify that we want to pay transaction fees with the WETH asset from our shielded account.

TransactionOptions: - revokerId: The id of the revoker chosen. - We set the paymaster to the address of the paymaster contract (ERC-4337), which will sponsor this transaction and will be paid in return for it. - We finally set theviaBundler option to true, as we want this transaction to be relayed through the bundler thus keeping our privacy intact.

But why and where are we paying fees?

Unlike the DEPOSIT, we don't want to send this transaction with our public wallet. This will expose our wallet's public address on block explorers and privacy will be harmed because anyone can inspect and match the previous deposit transaction and this transfer transaction. Hence, linking them. To resolve this issue, we will send the transaction through a third party, in exchange for some fee. One possible third party is the bundler node from EIP 4337. This fee, paid in WETH, is sent to the bundler ultimately. Note that we don't input the fee value, it is determined using SDK automatically as of now - you can see the value after transaction creation using the tx.feeValue property.

The rest of the steps remain the same - creating, signing and proving the transaction to get the ZTransaction.

const tx: Transaction = await zkfi.createTransaction(req);

const signedTx: Transaction = await zkfi.signTransaction(tx);

const ztx: ZTransaction = await zkfi.proveTransaction(signedTx);

Now, you can make calling transact(...) method with ztx.toSolidityInput() an EIP-4337 UserOperation and relay it via bundler.

We are currently working on a module to convert ZTransaction to a EIP-4337 UserOperationand send the transaction via bundler with a single call.

Withdraw from a shielded account

In case for any reason you want to withdraw assets from your shielded account to any public address you can follow the same steps as the above transfer transaction, but with type set to TransactionType.WITHDRAW.

import { parseEther } from 'viem';
import { TransactionRequest, TransactionType } from '@zkfi-tech/shared-types';

const req: TransactionRequest = {
  type: TransactionType.WITHDRAW,
  assetIds: [65537],
  values: [parseEther('1')],
  feeAssetId: 65537,
  to: 'carl.eth',
}

In this case what you specify as to is a public address or ENS (it is not resolved to a shielded address) where funds will be sent.

const tx: Transaction = await zkfi.createTransaction(req);

const signedTx: Transaction = await zkfi.signTransaction(tx);

const ztx: ZTransaction = await zkfi.proveTransaction(signedTx);

Last updated