Skip to content

Payroll

This usecase explains how to implement private payroll.

In this usecase, we explain a triparty system handling private transfers for contractor payouts and payroll.

The Software that exposes the private payments to their ‘users’, which are CFOs running payroll. The Payroll SaaS has a website and manages company accounts.

The Payroll SaaS does not hold funds for their users. Instead they use a multisig to manage their funds.

For this usecase we ensure the following requirements hold:

  1. Recipients cannot see salaries of other recipients.
  2. Recipients cannot see Sender funds (cold or hot wallets).
  3. Sender can track payments.
  4. Payroll SaaS can display payment success.

Effectively we do not want employees to know what their colleagues are being paid, nor how much money the company has.

We’ll use Union Private Payments to implement the following protocol.

  1. The Sender provides Recipients to the Payment SaaS (through their UI or API).
  2. The Payment SaaS generates unsigned transaction.
  3. The Payment SaaS requests the Sender to sign the unsigned transaction.
  4. The Payment SaaS submits the transaction on behalf of the Sender.
  5. The Payment SaaS monitors progress.
  6. (optionally) The Payment SaaS can send a notification to the recipient/update their dashboard.
  7. The Payment SaaS submits the redemption transaction.
  8. The Recipient has access to the funds.

With this workflow, we ensure compliance and that the Sender always has a full overview of transactional data. The Payment SaaS can choose to KYC recipients depending on their regulatory need.

Here we have an example implementation of the above protocol. This abstraction use viem to initialize the clients.

import {
import Domain
Domain
} from "@unionlabs/payments";
import {
import Attestor
Attestor
,
import EvmWalletClient
EvmWalletClient
,
import Payment
Payment
,
import Prover
Prover
,
} from "@unionlabs/payments/promises";
import * as
import Viem
Viem
from "viem";
import * as
import ViemAccounts
ViemAccounts
from "viem/accounts";
import * as
import ViemChains
ViemChains
from "viem/chains";
declare const
const privateKey: `0x${string}`
privateKey
: `0x${string}`;
declare const
const privateKey2: `0x${string}`
privateKey2
: `0x${string}`;
// Make sure to replace this with your own RPC url.
declare const
const BASE_RPC: string
BASE_RPC
: string;
declare const
const ASSET_ADDRESS: Domain.Erc20Address
ASSET_ADDRESS
:
import Domain
Domain
.
type Erc20Address = `0x${string}` & Brand<"Erc20Address">
Erc20Address
;
declare const
const BASE_UNIVERSAL_CHAIN_ID: string & Brand<"UniversalChainId">
BASE_UNIVERSAL_CHAIN_ID
:
import Domain
Domain
.
type UniversalChainId = string & Brand<"UniversalChainId">
UniversalChainId
;
declare const
const BENEFICIARY: Domain.Erc20Address
BENEFICIARY
:
import Domain
Domain
.
type Erc20Address = `0x${string}` & Brand<"Erc20Address">
Erc20Address
;
/**
* The wallet controlled by the sender. This could be constructed
* in the UI of the SaaS product.
*/
const
const senderWallet: EvmWalletClient.EvmWalletClient

The wallet controlled by the sender. This could be constructed in the UI of the SaaS product.

senderWallet
= await
import EvmWalletClient
EvmWalletClient
.
const fromViem: (config: {
account: Viem.Account | undefined;
batch?: {
multicall?: boolean | Viem.Prettify<Viem.MulticallBatchOptions> | undefined;
} | undefined;
cacheTime: number;
ccipRead?: false | {
request?: (parameters: Viem.CcipRequestParameters) => Promise<CcipRequestReturnType>;
} | undefined;
chain: Viem.Chain | undefined;
experimental_blockTag?: Viem.BlockTag | undefined;
key: string;
name: string;
pollingInterval: number;
... 31 more ...;
extend: <client>(fn: (client: Viem.Client<...>) => client) => Viem.Client<...>;
}) => Promise<...>
fromViem
(
import Viem
Viem
.
createWalletClient<Viem.HttpTransport<undefined, false>, {
blockExplorers: {
readonly default: {
readonly name: "Basescan";
readonly url: "https://basescan.org";
readonly apiUrl: "https://api.basescan.org/api";
};
};
blockTime: 2000;
contracts: {
readonly disputeGameFactory: {
readonly 1: {
readonly address: "0x43edB88C4B80fDD2AdFF2412A7BebF9dF42cB40e";
};
};
readonly l2OutputOracle: {
readonly 1: {
readonly address: "0x56315b90c40730925ec5485cf004d835058518A0";
};
};
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 5022;
};
readonly portal: {
readonly 1: {
readonly address: "0x49048044D57e1C92A77f79988d21Fa8fAF74E97e";
readonly blockCreated: 17482143;
};
};
readonly l1StandardBridge: {
readonly 1: {
readonly address: "0x3154Cf16ccdb4C6d922629664174b904d80F2C35";
readonly blockCreated: 17482143;
};
};
readonly gasPriceOracle: {
readonly address: "0x420000000000000000000000000000000000000F";
};
readonly l1Block: {
readonly address: "0x4200000000000000000000000000000000000015";
};
readonly l2CrossDomainMessenger: {
readonly address: "0x4200000000000000000000000000000000000007";
};
readonly l2Erc721Bridge: {
readonly address: "0x4200000000000000000000000000000000000014";
};
readonly l2StandardBridge: {
readonly address: "0x4200000000000000000000000000000000000010";
};
readonly l2ToL1MessagePasser: {
readonly address: "0x4200000000000000000000000000000000000016";
};
};
... 11 more ...;
serializers: {
readonly transaction: (transaction: ViemChains.OpStackTransactionSerializable, signature?: Viem.Signature) => `0x02${string}` | `0x01${string}` | `0x03${string}` | `0x04${string}` | Viem.TransactionSerializedLegacy | `0x7e${string}`;
};
}, {
...;
}, []>(parameters: {
...;
}): {
...;
}
export createWalletClient

Creates a Wallet Client with a given Transport configured for a Chain.

A Wallet Client is an interface to interact with Ethereum Account(s) and provides the ability to retrieve accounts, execute transactions, sign messages, etc. through Wallet Actions.

The Wallet Client supports signing over:

@paramconfig - WalletClientConfig

@returnsA Wallet Client. WalletClient

@example

// JSON-RPC Account import { createWalletClient, custom } from 'viem' import { mainnet } from 'viem/chains'

const client = createWalletClient({ chain: mainnet, transport: custom(window.ethereum), })

@example

// Local Account import { createWalletClient, custom } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains'

const client = createWalletClient({ account: privateKeyToAccount('0x…') chain: mainnet, transport: http(), })

createWalletClient
({
account?: `0x${string}` | {
address: Viem.Address;
nonceManager?: Viem.NonceManager | undefined;
sign: (parameters: {
hash: Viem.Hash;
}) => Promise<Viem.Hex>;
signAuthorization: (parameters: Viem.AuthorizationRequest) => Promise<ViemAccounts.SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: Viem.SignableMessage;
}) => Promise<Viem.Hex>;
signTransaction: <serializer extends Viem.SerializeTransactionFn<Viem.TransactionSerializable> = Viem.SerializeTransactionFn<Viem.TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Viem.Hex>;
signTypedData: <const typedData extends Viem.TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: Viem.TypedDataDefinition<typedData, primaryType>) => Promise<Viem.Hex>;
publicKey: Viem.Hex;
source: "privateKey";
type: "local";
} | Viem.Account | undefined

The Account to use for the Client. This will be used for Actions that require an account as an argument.

account
:
import ViemAccounts
ViemAccounts
.
function privateKeyToAccount(privateKey: Viem.Hex, options?: ViemAccounts.PrivateKeyToAccountOptions): Viem.PrivateKeyAccount
export privateKeyToAccount

@description Creates an Account from a private key.

@returnsA Private Key Account.

privateKeyToAccount
(
const privateKey: `0x${string}`
privateKey
),
chain?: Viem.Chain | {
blockExplorers: {
readonly default: {
readonly name: "Basescan";
readonly url: "https://basescan.org";
readonly apiUrl: "https://api.basescan.org/api";
};
};
blockTime: 2000;
contracts: {
readonly disputeGameFactory: {
readonly 1: {
readonly address: "0x43edB88C4B80fDD2AdFF2412A7BebF9dF42cB40e";
};
};
readonly l2OutputOracle: {
readonly 1: {
readonly address: "0x56315b90c40730925ec5485cf004d835058518A0";
};
};
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 5022;
};
readonly portal: {
readonly 1: {
readonly address: "0x49048044D57e1C92A77f79988d21Fa8fAF74E97e";
readonly blockCreated: 17482143;
};
};
readonly l1StandardBridge: {
readonly 1: {
readonly address: "0x3154Cf16ccdb4C6d922629664174b904d80F2C35";
readonly blockCreated: 17482143;
};
};
readonly gasPriceOracle: {
readonly address: "0x420000000000000000000000000000000000000F";
};
readonly l1Block: {
readonly address: "0x4200000000000000000000000000000000000015";
};
readonly l2CrossDomainMessenger: {
readonly address: "0x4200000000000000000000000000000000000007";
};
readonly l2Erc721Bridge: {
readonly address: "0x4200000000000000000000000000000000000014";
};
readonly l2StandardBridge: {
readonly address: "0x4200000000000000000000000000000000000010";
};
readonly l2ToL1MessagePasser: {
readonly address: "0x4200000000000000000000000000000000000016";
};
};
... 11 more ...;
serializers: {
readonly transaction: (transaction: ViemChains.OpStackTransactionSerializable, signature?: Viem.Signature) => `0x02${string}` | `0x01${string}` | `0x03${string}` | `0x04${string}` | Viem.TransactionSerializedLegacy | `0x7e${string}`;
};
} | undefined

Chain for the client.

chain
:
import ViemChains
ViemChains
.
const base: {
blockExplorers: {
readonly default: {
readonly name: "Basescan";
readonly url: "https://basescan.org";
readonly apiUrl: "https://api.basescan.org/api";
};
};
blockTime: 2000;
contracts: {
readonly disputeGameFactory: {
readonly 1: {
readonly address: "0x43edB88C4B80fDD2AdFF2412A7BebF9dF42cB40e";
};
};
readonly l2OutputOracle: {
readonly 1: {
readonly address: "0x56315b90c40730925ec5485cf004d835058518A0";
};
};
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 5022;
};
readonly portal: {
readonly 1: {
readonly address: "0x49048044D57e1C92A77f79988d21Fa8fAF74E97e";
readonly blockCreated: 17482143;
};
};
readonly l1StandardBridge: {
readonly 1: {
readonly address: "0x3154Cf16ccdb4C6d922629664174b904d80F2C35";
readonly blockCreated: 17482143;
};
};
readonly gasPriceOracle: {
readonly address: "0x420000000000000000000000000000000000000F";
};
readonly l1Block: {
readonly address: "0x4200000000000000000000000000000000000015";
};
readonly l2CrossDomainMessenger: {
readonly address: "0x4200000000000000000000000000000000000007";
};
readonly l2Erc721Bridge: {
readonly address: "0x4200000000000000000000000000000000000014";
};
readonly l2StandardBridge: {
readonly address: "0x4200000000000000000000000000000000000010";
};
readonly l2ToL1MessagePasser: {
readonly address: "0x4200000000000000000000000000000000000016";
};
};
... 11 more ...;
serializers: {
readonly transaction: (transaction: ViemChains.OpStackTransactionSerializable, signature?: Viem.Signature) => `0x02${string}` | `0x01${string}` | `0x03${string}` | `0x04${string}` | Viem.TransactionSerializedLegacy | `0x7e${string}`;
};
}
export base
base
,
transport: Viem.HttpTransport<undefined, false>

The RPC transport

transport
:
import Viem
Viem
.
http<undefined, false>(url?: string | undefined, config?: Viem.HttpTransportConfig<undefined, false> | undefined): Viem.HttpTransport<undefined, false>
export http

@description Creates a HTTP transport that connects to a JSON-RPC API.

http
(
const BASE_RPC: string
BASE_RPC
),
}),
);
/**
* This wallet is used on the backend of the SaaS.
*/
const
const saasWallet: EvmWalletClient.EvmWalletClient

This wallet is used on the backend of the SaaS.

saasWallet
= await
import EvmWalletClient
EvmWalletClient
.
const fromViem: (config: {
account: Viem.Account | undefined;
batch?: {
multicall?: boolean | Viem.Prettify<Viem.MulticallBatchOptions> | undefined;
} | undefined;
cacheTime: number;
ccipRead?: false | {
request?: (parameters: Viem.CcipRequestParameters) => Promise<CcipRequestReturnType>;
} | undefined;
chain: Viem.Chain | undefined;
experimental_blockTag?: Viem.BlockTag | undefined;
key: string;
name: string;
pollingInterval: number;
... 31 more ...;
extend: <client>(fn: (client: Viem.Client<...>) => client) => Viem.Client<...>;
}) => Promise<...>
fromViem
(
import Viem
Viem
.
createWalletClient<Viem.HttpTransport<undefined, false>, {
blockExplorers: {
readonly default: {
readonly name: "Basescan";
readonly url: "https://basescan.org";
readonly apiUrl: "https://api.basescan.org/api";
};
};
blockTime: 2000;
contracts: {
readonly disputeGameFactory: {
readonly 1: {
readonly address: "0x43edB88C4B80fDD2AdFF2412A7BebF9dF42cB40e";
};
};
readonly l2OutputOracle: {
readonly 1: {
readonly address: "0x56315b90c40730925ec5485cf004d835058518A0";
};
};
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 5022;
};
readonly portal: {
readonly 1: {
readonly address: "0x49048044D57e1C92A77f79988d21Fa8fAF74E97e";
readonly blockCreated: 17482143;
};
};
readonly l1StandardBridge: {
readonly 1: {
readonly address: "0x3154Cf16ccdb4C6d922629664174b904d80F2C35";
readonly blockCreated: 17482143;
};
};
readonly gasPriceOracle: {
readonly address: "0x420000000000000000000000000000000000000F";
};
readonly l1Block: {
readonly address: "0x4200000000000000000000000000000000000015";
};
readonly l2CrossDomainMessenger: {
readonly address: "0x4200000000000000000000000000000000000007";
};
readonly l2Erc721Bridge: {
readonly address: "0x4200000000000000000000000000000000000014";
};
readonly l2StandardBridge: {
readonly address: "0x4200000000000000000000000000000000000010";
};
readonly l2ToL1MessagePasser: {
readonly address: "0x4200000000000000000000000000000000000016";
};
};
... 11 more ...;
serializers: {
readonly transaction: (transaction: ViemChains.OpStackTransactionSerializable, signature?: Viem.Signature) => `0x02${string}` | `0x01${string}` | `0x03${string}` | `0x04${string}` | Viem.TransactionSerializedLegacy | `0x7e${string}`;
};
}, {
...;
}, []>(parameters: {
...;
}): {
...;
}
export createWalletClient

Creates a Wallet Client with a given Transport configured for a Chain.

A Wallet Client is an interface to interact with Ethereum Account(s) and provides the ability to retrieve accounts, execute transactions, sign messages, etc. through Wallet Actions.

The Wallet Client supports signing over:

@paramconfig - WalletClientConfig

@returnsA Wallet Client. WalletClient

@example

// JSON-RPC Account import { createWalletClient, custom } from 'viem' import { mainnet } from 'viem/chains'

const client = createWalletClient({ chain: mainnet, transport: custom(window.ethereum), })

@example

// Local Account import { createWalletClient, custom } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains'

const client = createWalletClient({ account: privateKeyToAccount('0x…') chain: mainnet, transport: http(), })

createWalletClient
({
account?: `0x${string}` | {
address: Viem.Address;
nonceManager?: Viem.NonceManager | undefined;
sign: (parameters: {
hash: Viem.Hash;
}) => Promise<Viem.Hex>;
signAuthorization: (parameters: Viem.AuthorizationRequest) => Promise<ViemAccounts.SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: Viem.SignableMessage;
}) => Promise<Viem.Hex>;
signTransaction: <serializer extends Viem.SerializeTransactionFn<Viem.TransactionSerializable> = Viem.SerializeTransactionFn<Viem.TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Viem.Hex>;
signTypedData: <const typedData extends Viem.TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: Viem.TypedDataDefinition<typedData, primaryType>) => Promise<Viem.Hex>;
publicKey: Viem.Hex;
source: "privateKey";
type: "local";
} | Viem.Account | undefined

The Account to use for the Client. This will be used for Actions that require an account as an argument.

account
:
import ViemAccounts
ViemAccounts
.
function privateKeyToAccount(privateKey: Viem.Hex, options?: ViemAccounts.PrivateKeyToAccountOptions): Viem.PrivateKeyAccount
export privateKeyToAccount

@description Creates an Account from a private key.

@returnsA Private Key Account.

privateKeyToAccount
(
const privateKey2: `0x${string}`
privateKey2
),
chain?: Viem.Chain | {
blockExplorers: {
readonly default: {
readonly name: "Basescan";
readonly url: "https://basescan.org";
readonly apiUrl: "https://api.basescan.org/api";
};
};
blockTime: 2000;
contracts: {
readonly disputeGameFactory: {
readonly 1: {
readonly address: "0x43edB88C4B80fDD2AdFF2412A7BebF9dF42cB40e";
};
};
readonly l2OutputOracle: {
readonly 1: {
readonly address: "0x56315b90c40730925ec5485cf004d835058518A0";
};
};
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 5022;
};
readonly portal: {
readonly 1: {
readonly address: "0x49048044D57e1C92A77f79988d21Fa8fAF74E97e";
readonly blockCreated: 17482143;
};
};
readonly l1StandardBridge: {
readonly 1: {
readonly address: "0x3154Cf16ccdb4C6d922629664174b904d80F2C35";
readonly blockCreated: 17482143;
};
};
readonly gasPriceOracle: {
readonly address: "0x420000000000000000000000000000000000000F";
};
readonly l1Block: {
readonly address: "0x4200000000000000000000000000000000000015";
};
readonly l2CrossDomainMessenger: {
readonly address: "0x4200000000000000000000000000000000000007";
};
readonly l2Erc721Bridge: {
readonly address: "0x4200000000000000000000000000000000000014";
};
readonly l2StandardBridge: {
readonly address: "0x4200000000000000000000000000000000000010";
};
readonly l2ToL1MessagePasser: {
readonly address: "0x4200000000000000000000000000000000000016";
};
};
... 11 more ...;
serializers: {
readonly transaction: (transaction: ViemChains.OpStackTransactionSerializable, signature?: Viem.Signature) => `0x02${string}` | `0x01${string}` | `0x03${string}` | `0x04${string}` | Viem.TransactionSerializedLegacy | `0x7e${string}`;
};
} | undefined

Chain for the client.

chain
:
import ViemChains
ViemChains
.
const base: {
blockExplorers: {
readonly default: {
readonly name: "Basescan";
readonly url: "https://basescan.org";
readonly apiUrl: "https://api.basescan.org/api";
};
};
blockTime: 2000;
contracts: {
readonly disputeGameFactory: {
readonly 1: {
readonly address: "0x43edB88C4B80fDD2AdFF2412A7BebF9dF42cB40e";
};
};
readonly l2OutputOracle: {
readonly 1: {
readonly address: "0x56315b90c40730925ec5485cf004d835058518A0";
};
};
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 5022;
};
readonly portal: {
readonly 1: {
readonly address: "0x49048044D57e1C92A77f79988d21Fa8fAF74E97e";
readonly blockCreated: 17482143;
};
};
readonly l1StandardBridge: {
readonly 1: {
readonly address: "0x3154Cf16ccdb4C6d922629664174b904d80F2C35";
readonly blockCreated: 17482143;
};
};
readonly gasPriceOracle: {
readonly address: "0x420000000000000000000000000000000000000F";
};
readonly l1Block: {
readonly address: "0x4200000000000000000000000000000000000015";
};
readonly l2CrossDomainMessenger: {
readonly address: "0x4200000000000000000000000000000000000007";
};
readonly l2Erc721Bridge: {
readonly address: "0x4200000000000000000000000000000000000014";
};
readonly l2StandardBridge: {
readonly address: "0x4200000000000000000000000000000000000010";
};
readonly l2ToL1MessagePasser: {
readonly address: "0x4200000000000000000000000000000000000016";
};
};
... 11 more ...;
serializers: {
readonly transaction: (transaction: ViemChains.OpStackTransactionSerializable, signature?: Viem.Signature) => `0x02${string}` | `0x01${string}` | `0x03${string}` | `0x04${string}` | Viem.TransactionSerializedLegacy | `0x7e${string}`;
};
}
export base
base
,
transport: Viem.HttpTransport<undefined, false>

The RPC transport

transport
:
import Viem
Viem
.
http<undefined, false>(url?: string | undefined, config?: Viem.HttpTransportConfig<undefined, false> | undefined): Viem.HttpTransport<undefined, false>
export http

@description Creates a HTTP transport that connects to a JSON-RPC API.

http
(
const BASE_RPC: string
BASE_RPC
),
}),
);
/**
* This is a test API_KEY.
* Feel free to use it for test integrations, but it is not suitable for production usage.
*/
const
const ATTESTOR_KEY: "6af6f8068d38ebf6666b8db98f9b8b42959ab62646764bc290e663f7abb49eea"

This is a test API_KEY. Feel free to use it for test integrations, but it is not suitable for production usage.

ATTESTOR_KEY
=
"6af6f8068d38ebf6666b8db98f9b8b42959ab62646764bc290e663f7abb49eea";
// The wallet client is run on the backend of the SaaS.
// const paymentClient = Client.make({
// rpcUrl: new URL(BASE_RPC),
// assetAddress: ASSET_ADDRESS,
// chainId: 8453n,
// attestorApiKey: ATTESTOR_KEY,
// proverUrl: "http://localhost:50051"
// });
/**
* Attestation service is run on the backend of the Saas.
*/
const
const attestor: Attestor.Attestor

Attestation service is run on the backend of the Saas.

attestor
= await
import Attestor
Attestor
.
const make: (options: Parameters<(options: {
readonly apiKey: string;
readonly baseUrl?: string | URL | undefined;
}) => Layer<Attestor, SystemError, never>>[0]) => Promise<Attestor.Attestor>
make
({
apiKey: string
apiKey
:
const ATTESTOR_KEY: "6af6f8068d38ebf6666b8db98f9b8b42959ab62646764bc290e663f7abb49eea"

This is a test API_KEY. Feel free to use it for test integrations, but it is not suitable for production usage.

ATTESTOR_KEY
,
baseUrl?: string | URL | undefined
baseUrl
: "http://localhost:50051",
});
/**
* Prover service is run on the backend of the Saas.
*/
const
const prover: Prover.Prover

Prover service is run on the backend of the Saas.

prover
= await
import Prover
Prover
.
const make: (options: Parameters<(options: {
readonly proverUrl?: string | URL | undefined;
}) => Layer<Prover, SystemError, never>>[0]) => Promise<Prover.Prover>
make
({
proverUrl?: string | URL | undefined
proverUrl
: "http://localhost:50052",
});
/**
* The `paymentKey` is generated by the SaaS and optionally shared with the
* **sender**. Store this securely to ensure transfers are inspectable later.
*/
const
const paymentKey: Domain.PaymentKey

The paymentKey is generated by the SaaS and optionally shared with the sender. Store this securely to ensure transfers are inspectable later.

paymentKey
= await
import Payment
Payment
.
const generateKey: () => Promise<Domain.PaymentKey>
generateKey
();
/**
* Beneficiaries are provided by the **sender** ahead of time.
* This is the address of the **recipient**.
*/
const
const beneficiaries: Domain.Erc20Address[]

Beneficiaries are provided by the sender ahead of time. This is the address of the recipient.

beneficiaries
= [
import Domain
Domain
.
const Erc20Address: Brand<in out K extends string | symbol>.Constructor
(args: `0x${string}`) => Domain.Erc20Address

Constructs a branded type from a value of type A, throwing an error if the provided A is not valid.

Erc20Address
("0x123"),
/* ... */
];
const
const depositAddress: {
beneficiaries: [Domain.Erc20Address, Domain.Erc20Address, Domain.Erc20Address, Domain.Erc20Address];
zAssetAddress: Domain.ZAssetAddress;
destinationChainId: string & Brand<"UniversalChainId">;
}
depositAddress
= await
import Payment
Payment
.
const getDepositAddress: (options: {
paymentKey: Domain.PaymentKey;
beneficiaries: ReadonlyArray<Domain.Erc20Address>;
destinationChainId: Domain.UniversalChainId;
}) => Promise<{
beneficiaries: [Domain.Erc20Address, Domain.Erc20Address, Domain.Erc20Address, Domain.Erc20Address];
zAssetAddress: Domain.ZAssetAddress;
destinationChainId: string & Brand<"UniversalChainId">;
}>
getDepositAddress
({
paymentKey: Domain.PaymentKey
paymentKey
,
beneficiaries: readonly Domain.Erc20Address[]
beneficiaries
,
destinationChainId: string & Brand<"UniversalChainId">
destinationChainId
:
const BASE_UNIVERSAL_CHAIN_ID: string & Brand<"UniversalChainId">
BASE_UNIVERSAL_CHAIN_ID
,
});
/**
* The Payroll SaaS creates the deposit parameters and sends
* the prepared tx to be signed and sent by the sender.
*/
const
const depositResult: readonly [{
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}, ...{
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}[]]

The Payroll SaaS creates the deposit parameters and sends the prepared tx to be signed and sent by the sender.

depositResult
= await
import Payment
Payment
.
const prepareDeposit: (options: {
sourceWalletClient: WalletClient;
} & Parameters<typeof prepareDeposit>[0]) => Promise<readonly [{
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}, ...{
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}[]]>
prepareDeposit
({
DepositOptions.depositAddress: DepositAddress
depositAddress
,
DepositOptions.amount: bigint

Amount to deposit (in underlying token's smallest unit)

amount
: 1n,
sourceWalletClient: WalletClient
sourceWalletClient
:
const saasWallet: EvmWalletClient.EvmWalletClient

This wallet is used on the backend of the SaaS.

saasWallet
,
DepositOptions.srcErc20Address: Domain.Erc20Address
srcErc20Address
:
const ASSET_ADDRESS: Domain.Erc20Address
ASSET_ADDRESS
,
DepositOptions.destinationChainId: string & Brand<"UniversalChainId">
destinationChainId
:
const BASE_UNIVERSAL_CHAIN_ID: string & Brand<"UniversalChainId">
BASE_UNIVERSAL_CHAIN_ID
,
});
/**
* Nullifier is computed by the owner of the payment key.
*/
const
const nullifier: Domain.Nullifier

Nullifier is computed by the owner of the payment key.

nullifier
= await
import Payment
Payment
.
const getNullifier: (options: {
paymentKey: Domain.PaymentKey;
destinationChainId: Domain.UniversalChainId;
}) => Promise<Domain.Nullifier>
getNullifier
({
destinationChainId: string & Brand<"UniversalChainId">
destinationChainId
:
const BASE_UNIVERSAL_CHAIN_ID: string & Brand<"UniversalChainId">
BASE_UNIVERSAL_CHAIN_ID
,
paymentKey: Domain.PaymentKey
paymentKey
,
});
/**
* Proof generation is run on the backend of the SaaS.
*/
const
const proof: Proof

Proof generation is run on the backend of the SaaS.

proof
= await
import Payment
Payment
.
const generateProof: (options: {
destinationPublicClient: PublicClient.PublicClient;
sourcePublicClient: PublicClient.PublicClient;
publicClient: PublicClient.PublicClient;
prover: Prover.Prover;
} & Parameters<typeof generateProof>[0]) => Promise<Proof>
generateProof
({
paymentKey: Domain.PaymentKey
paymentKey
,
depositAddress: DepositAddress
depositAddress
,
nullifier: Domain.Nullifier
nullifier
,
amount: bigint
amount
: 1n,
clientIds: number[]
clientIds
: [5],
beneficiary: Domain.Erc20Address
beneficiary
:
const BENEFICIARY: Domain.Erc20Address
BENEFICIARY
,
selectedClientId: number
selectedClientId
: 5,
srcChainId: string & Brand<"UniversalChainId">
srcChainId
:
const BASE_UNIVERSAL_CHAIN_ID: string & Brand<"UniversalChainId">
BASE_UNIVERSAL_CHAIN_ID
,
srcErc20Address: Domain.Erc20Address
srcErc20Address
:
const ASSET_ADDRESS: Domain.Erc20Address
ASSET_ADDRESS
,
dstErc20Address: Domain.Erc20Address
dstErc20Address
:
const ASSET_ADDRESS: Domain.Erc20Address
ASSET_ADDRESS
,
publicClient: PublicClient.PublicClient
publicClient
,
sourcePublicClient: PublicClient.PublicClient
sourcePublicClient
:
const publicClient: PublicClient.PublicClient
publicClient
,
destinationPublicClient: PublicClient.PublicClient
destinationPublicClient
:
const publicClient: PublicClient.PublicClient
publicClient
,
prover: Prover.Prover
prover
,
});
const
const attestation: {
readonly id: string;
readonly hash: `0x${string}`;
readonly signature: `0x${string}`;
readonly attestedMessage: `0x${string}`;
readonly signerAddress: `0x${string}`;
}
attestation
= await
const attestor: Attestor.Attestor

Attestation service is run on the backend of the Saas.

attestor
.
Attestor.get: (payload: {
unspendableAddress: Address;
beneficiary: Address;
}) => Promise<{
readonly id: string;
readonly hash: `0x${string}`;
readonly signature: `0x${string}`;
readonly attestedMessage: `0x${string}`;
readonly signerAddress: `0x${string}`;
}>
get
({
unspendableAddress: `0x${string}`
unspendableAddress
:
const depositAddress: {
beneficiaries: [Domain.Erc20Address, Domain.Erc20Address, Domain.Erc20Address, Domain.Erc20Address];
zAssetAddress: Domain.ZAssetAddress;
destinationChainId: string & Brand<"UniversalChainId">;
}
depositAddress
.
zAssetAddress: Domain.ZAssetAddress
zAssetAddress
,
beneficiary: `0x${string}`
beneficiary
:
const BENEFICIARY: Domain.Erc20Address
BENEFICIARY
,
});
/**
* To finalize the payment, redeem must be called. This ensures the funds arrive
* unwrapped in the account of the **recipient**. If the recipients wants to receive
* private assets and unwrap themselves, this does not need to be called, but the
* `paymentKey` needs to be shared with the recipient.
*/
const
const preparedRedemption: {
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}

To finalize the payment, redeem must be called. This ensures the funds arrive unwrapped in the account of the recipient. If the recipients wants to receive private assets and unwrap themselves, this does not need to be called, but the paymentKey needs to be shared with the recipient.

preparedRedemption
= await
import Payment
Payment
.
const prepareRedemption: (options: {
destinationWalletClient: WalletClient;
} & Parameters<typeof prepareRedemption>[0]) => Promise<{
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}>
prepareRedemption
({
proof: Proof
proof
,
dstErc20Address: Domain.Erc20Address
dstErc20Address
:
const ASSET_ADDRESS: Domain.Erc20Address
ASSET_ADDRESS
,
attestation: {
readonly id: string;
readonly hash: `0x${string}`;
readonly signature: `0x${string}`;
readonly attestedMessage: `0x${string}`;
readonly signerAddress: `0x${string}`;
}
attestation
,
destinationWalletClient: WalletClient
destinationWalletClient
:
const senderWallet: EvmWalletClient.EvmWalletClient

The wallet controlled by the sender. This could be constructed in the UI of the SaaS product.

senderWallet
,
// @annotate: of the destination
universalChainId: string & Brand<"UniversalChainId">
universalChainId
:
const BASE_UNIVERSAL_CHAIN_ID: string & Brand<"UniversalChainId">
BASE_UNIVERSAL_CHAIN_ID
,
});
const submittedRedemption =
const saasWallet: EvmWalletClient.EvmWalletClient

This wallet is used on the backend of the SaaS.

saasWallet
.
WalletClient.signAndSubmit: (request: {
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}) => Promise<{
readonly _tag: "SubmissionEvm";
readonly hash: Domain.TxHash;
}>
signAndSubmit
(
const preparedRedemption: {
readonly _tag: "PreparedEvm";
readonly kind: "Erc20.Approve" | "Erc20.Wrap" | "ZAsset.Transfer" | "LoopbackClient.Update";
readonly universalChainId: Domain.UniversalChainId;
readonly contractAddress: Domain.Erc20Address;
readonly abi: Viem.Abi;
readonly functionName: string;
readonly args: ReadonlyArray<unknown>;
}

To finalize the payment, redeem must be called. This ensures the funds arrive unwrapped in the account of the recipient. If the recipients wants to receive private assets and unwrap themselves, this does not need to be called, but the paymentKey needs to be shared with the recipient.

preparedRedemption
);
const submittedRedemption: Promise<{
readonly _tag: "SubmissionEvm";
readonly hash: Domain.TxHash;
}>

With the above protocol, we ensure all requirements from the workflow are accomplished. Companies ensure that their employees cannot see origin of funds, or salaries being paid out to their colleagues, while regular accounting tools can provide exactly the same experience as with regular transfers. In the backend, we need to introduce 1 additional onchain transaction in the payment flow, although if contractors choose to receive private assets, this can be omitted.