Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.canton.network/llms.txt

Use this file to discover all available pages before exploring further.

This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/canton-network-overview/index.rst Reviewers: Skip this section. Remove markers after final approval.

Canton Network Overview

High-level Overview

Canton Network is a public layer 1 blockchain network with privacy. It is designed for financial institutions and DeFi alike to facilitate secure, interoperable, and privacy-preserving transactions and drive the confluence of TradFi and DeFi. Key Features:
  • It uniquely balances the decentralization of public blockchains with the privacy and controls required for financial markets.
  • It enables real-time, secure synchronization and settlement across multiple asset classes on a shared, interoperable infrastructure.
  • It allows assets and data to move across applications with real-time synchronization and guaranteed privacy.
Technology: The Canton Network is designed as a “network of networks,” where each participating institution maintains its own sub-ledger while connecting with others via a shared synchronization layer. Governance: The Global Synchronizer Foundation (GSF), an independent, non-profit body under the Linux Foundation, governs the global synchronizer. Participants: Canton Network was launched in May 2023 by a group of major institutions, and continues to be backed by the world’s largest financial and crypto institutions alike. Participants include Goldman Sachs, HSBC and BNP Paribas, market infrastructure providers like DTCC and Deutsche Börse, and (crypto) trading firms like DRW and QCP.

Canton’s High-level architecture

Nodes and Consensus

The Canton network is composed of nodes known as validators that achieve consensus through synchronizers. Validator nodes are responsible for storing contract data and executing smart contract code. The synchronizers, in turn, distribute encrypted messages and facilitate transaction coordination. Transaction data is only distributed on a need-to-know basis to maintain confidentiality. This is the key delta to other chains:
  • In most other chains, all state and transactions get replicated to all nodes/validators.
  • In Canton, state and transactions get distributed only to nodes/validators that are specified in the smart contracts.

Parties

In Canton, parties are the core on-ledger identities, and are the wallet addresses, similar to an address or Externally Owned Account (EOA) on other blockchains. They are central to how permissions and privacy are managed within the network. Party Permissions and Roles Smart contracts specify permissions for different parties, dictating what they can and can’t do. Depending on their role, parties:
  • Validate specific transactions, such as a transfer of assets.
  • Control certain actions, like initiating transfers.
  • See specific state and transactions, such as a record of their holdings.
Privacy is maintained at the party level, meaning transaction and state data is only shared with the parties who need to see it, ensuring a high degree of confidentiality. Local parties vs External parties Parties come in two forms, internal and external. An internal party is created on the validator node, it gives a validator node submission rights and therefore holds its key on the validator node. Transactions are signed using the Validators own internal keys for signing (and thereby the validator operator has full control of everything that happens on the party). External parties are similar to how node interactions happens on other networks and therefore Externally Owned Accounts. In this case the signing key can be held externally and a signature is required alongside the transaction to authorize the action. For external parties the base flow follows three steps: Prepare a transaction, sign the transaction and submit the transaction. In this guide, when a party is referenced, it is referring to an external party unless otherwise specified. To read more about the differences between internal and external parties, see the Local and external parties documentation section here. Onboarding and Format Parties are formatted as name::fingerprint. The party name or hint is freely chosen at time of creation - there’s a maximum limit of 185 characters from [a-zA-Z0-9:-_ ], it must not use two consecutive colons, and must be unique in the namespace. The fingerprint is a unique identifier and a sha256 hash of the public key prefixed with ‘12’ (as indicated by the hash purpose). If you want to be able to derive your Party IDs from the public key, you can either use a static Party Name, and therefore derive the key from the fingerprint, or derive Party Name from the public key, too. To use a party, you must onboard it by submitting a topology transaction that authorizes a node to host it. The designated node must then submit a matching transaction to officially accept the hosting request. Instructions on how to do that can be found here. Party Hosting Since not every user wants to host a node, parties are associated with validator nodes. These validators “host” parties by: * Storing the party’s private data and making it available through an RPC (Remote Procedure Call) interface. * Participating in consensus on the party’s behalf. Crucially, even though a validator hosts a party, the party retains ultimate control by holding its own independent signing keys externally to the participant. To participate in the network, a party must designate one or more validator nodes to host their data. This relationship, known as Party Hosting, is established through a topology transaction. Advice on using Parties for wallet providers Unlike Ethereum or Bitcoin addresses, creating parties has a cost associated to them and they create state on the validator node which means that they’re not as ephemeral as on other chains. Therefore, it’s suggested to avoid using parties for use cases such as “deposit addresses”.
  • For wallets, it’s suggested to aim for one Party per key pair to represent the wallet.
  • For custodians, it’s suggested aiming for one Party per account/wallet.
  • For exchanges, it’s suggested aiming for one, or few parties for the exchange vault and using memo tags for tracking deposits. See here for more information.

Consequences & Implications

Reading Data and Validator State A key implication of Canton’s architecture for providing privacy, is how you read data. Unlike other blockchains where nodes are often ephemeral and interchangeable, in Canton, validators have state. This means that to access a party’s or user’s data, you must specifically connect to the validator that hosts that party. There is no single, all-encompassing blockchain RPC endpoint you can call to retrieve all data. Instead, you’ll need to use your node’s RPC for private data (“Ledger API”) and potentially an app provider’s API for their data (e.g., a “Scan API”). Advantages and Consequences The design of the Canton Network leads to several significant advantages:
  • Privacy: It enables true confidentiality at the smart contract level, as data is only distributed to the parties who have a legitimate need to see it.
  • Light Node Footprint: Nodes only process their own transactions, not the entire network’s, which keeps them lightweight and efficient.
  • Scalability: The network can be scaled by simply adding more nodes.
However, this architecture has the consequence of decentralized data access, as previously mentioned.
Implications for Wallet Providers
To offer services on the Canton Network, you will need a validator node to host your parties and your customers’ parties. You have two options for this: you can self-host a node or use a node-as-a-service provider. For wallets and custodians, this means your role extends beyond just safekeeping assets; you are also responsible for safekeeping your customers’ data and preserving their privacy. The Canton Network is designed to be agile and undergoes frequent upgrades. Node operators are asked to run nodes in three different environments: DevNet, TestNet, and MainNet to ensure that applications and integrations can be tested with new network upgrades. If you choose to self-host, be prepared to spin up and maintain nodes for all three environments. To stay informed and get support, it’s highly recommended that self-hosting node operators join the validator node operator community on Slack.
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/integrating-with-canton-network/index.rst Reviewers: Skip this section. Remove markers after final approval.

Integrating with the Canton Network

When integrating with the Canton Network, we recommend that wallet providers support the necessary features outlined below to optimal user experience. Additionally, there are optional features that can further enhance the integration and provide additional value to users.

Necessary Features

The following features are required for wallet providers to integrate with the Canton Network:
  • Support the CIP-0056 token standard to enable the holding and transferring of assets on the Canton Network. Documentation and guidance on how to implement this with the Wallet SDK is in the Token Standard section of this guide.
  • Provide support specifically for Canton Coin and USDCx. The Canton Coin package of Amulet is preinstalled with all validators and USDCx is issued with the Digital Asset Registry and that dars for that application can be found in the DAR Package Versions of the Utilities documentation.
  • Memo tag support to allow deposits to be sent to exchanges
  • UTXO management to reduce the number of UTXOs

Optional Features

While optional for wallet providers, the following features are strongly recommended to ensure full support for the Canton Network and maximize user adoption:
  • Canton Coin pre-approvals. Documentation on how to implement pre-approvals with the Wallet SDK are in the 2-step transfer vs 1-step transfer section of this guide.
  • dApp support by conforming to CIP-0103, the standard for wallet and dApp integration.
  • The requirement to hold and transfer USDCx is included in the Necessary Features section above, however there are additional levels of support for USDCx for wallet providers to support such as supporting xReserves deposits and withdrawals and integrating the xReserve UI into the wallet directly. The options and instructions are laid out in the USDCx Support for Wallets section of this guide.
  • Pre-approvals for DA Registry issued assets.

Install the Wallet SDK

For installing the @canton-network/wallet-sdk and @canton-network/dapp-sdk packages with npm, yarn, or pnpm, see the Wallet SDK Download page.

Hosting a Validator

As stated in the Implications for Wallet Providers section here, it’s important for wallet providers to have a validator to host their users’ parties. It’s also strongly advised to operate a node in all three network environments so that you can test and verify your applications and integration as the Canton Network evolves. Links to the node deployment docs are below depending on the deployment choice and environment. The guidance differs very little based on the environment - different URLs and arguments etc.:
  • MainNet docs
    • Docker Compose MainNet docs
    • Kubernetes MainNet docs
  • TestNet docs
    • Docker Compose TestNet docs
    • Kubernetes TestNet docs
  • DevNet
    • Docker Compose DevNet docs
    • Kubernetes DevNet docs
The Wallet integration guide is tailored to work with a LocalNet setup to make testing and verification easy.

Connecting to a Synchronizer

For onboarding a validator with the global synchronizer it is recommended to read the Splice documentation here: https://docs.dev.sync.global/validator_operator/validator_onboarding.html

Supporting Tokens and Applications

To integrate and support tokens, it is recommended to use the Splice documentation here: https://docs.sync.global/validator_operator/validator_onboarding.html If you are interested in building your own application, a good first place would be to utilize the CN quickstart: https://github.com/digital-asset/cn-quickstart
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/party-management/index.rst Reviewers: Skip this section. Remove markers after final approval.

Create an External Party (Wallet)

Overview

Parties represent acting entities in the network and all transaction happens between one or more parties. To understand more about parties see the Parties in the Overview. A detailed tutorial of the steps below can be seen in the External Signing Tutorial here using python example scripts. This document focuses on the steps required to create an external party using the Wallet SDK.

How do I quickly allocate a party?

Using the wallet SDK you can quickly allocate a party using the following code snippet:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTokenStandardDefault,
    createKeyPair,
    signTransactionHash,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'
import { pino } from 'pino'

const logger = pino({ name: '02-auth-localnet', level: 'info' })

// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const sdk = new WalletSDKImpl().configure({
    logger: logger,
    authFactory: localNetAuthDefault,
    ledgerFactory: localNetLedgerDefault,
    tokenStandardFactory: localNetTokenStandardDefault,
})

logger.info('SDK initialized')

await sdk.connect()
logger.info('Connected to ledger')

await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)
logger.info('Connected to topology')

const keyPair = createKeyPair()

logger.info('generated keypair')

const generatedParty = await sdk.userLedger?.generateExternalParty(
    keyPair.publicKey
)

if (!generatedParty) {
    throw new Error('Error creating prepared party')
}

logger.info('Signing the hash')
const signedHash = signTransactionHash(
    generatedParty.multiHash,
    keyPair.privateKey
)

const allocatedParty = await sdk.userLedger?.allocateExternalParty(
    signedHash,
    generatedParty
)

logger.info({ partyId: allocatedParty!.partyId }, 'Allocated party')
await sdk.setPartyId(allocatedParty!.partyId!)

logger.info(allocatedParty, 'Create ping command for party')

const createPingCommand = sdk.userLedger?.createPingCommand(
    allocatedParty!.partyId!
)

logger.info('Prepare command submission for ping create command')
const prepareResponse =
    await sdk.userLedger?.prepareSubmission(createPingCommand)

logger.info('Sign transaction hash')

const signedCommandHash = signTransactionHash(
    prepareResponse!.preparedTransactionHash!,
    keyPair.privateKey
)

logger.info('Submit command')

const response = await sdk.userLedger?.executeSubmissionAndWaitFor(
    prepareResponse!,
    signedCommandHash,
    keyPair.publicKey,
    v4()
)

logger.info(response, 'Executed command submission succeeded')

Create a key pair

The process for creating a key using standard encryption practices is similar that in other blockchains. The full details of supported cryptographic algorithms can be found Here. By default an Ed25519 encryption is used. There exists many libraries that can be used to generate such a key pair, you can do it simply with the WalletSDK using:
import { TopologyController } from '@canton-network/wallet-sdk'

export default async function () {
    // static method call
    TopologyController.createNewKeyPair()
}

Generating Keys from a Mnemonic Phrase (BIP-0039)

The Canton Network supports the generation of cryptographic keys using a mnemonic code or mnemonic sentence, following the BIP-0039 standard. Using a mnemonic phrase allows for deterministic key generation, which simplifies the backup and recovery process. Instead of managing individual private key files, you can recreate your keys across different environments using a human-readable sequence of words. A typescript example of generating an Ed25519 key pair with a BIP-0039 mnemonic phrase using the libraries bip39 and ed25519 as dependencies is shown below:
import { getPublicKeyFromPrivate } from '@canton-network/core-signing-lib'
import naclUtil from 'tweetnacl-util'
import * as bip39 from 'bip39'
import * as fs from 'fs'

export default async function createCantonKeyFromMnemonic() {
    try {
        // 1. Generate a new 24-word BIP-0039 mnemonic
        const mnemonic = bip39.generateMnemonic(256)
        console.log('Generated Mnemonic:', mnemonic)

        // 2. Convert mnemonic to a seed
        const seed = await bip39.mnemonicToSeed(mnemonic)

        // 3. Derive a 32-byte Private Key (first 32 bytes of the seed)
        const privateKey = naclUtil.encodeBase64(seed.slice(0, 32))
        const publicKey = getPublicKeyFromPrivate(privateKey)

        console.log('Private Key (bas64):', privateKey)
        console.log('Public Key (bas64):', publicKey)

        // 4. Save to a file for Canton Import
        fs.writeFileSync('canton_private_key.base64', privateKey)

        console.log(
            "\nSuccess: Private key saved to 'canton_private_key.base64'"
        )
        console.log('Keep your mnemonic phrase safe!')
    } catch (error) {
        console.error('An error occurred:', error)
    }
}

createCantonKeyFromMnemonic()

Choosing a party hint

The unique party id is defined as $::$. The partyHint is a user friendly name and can be anything that is unique for the fingerprint, e.g. “alice”, “bob” or “my-wallet-1”. If you want to be to derive your party IDs from the public key, you can use a static party hint for all parties with different fingerprints, or also derive party hint from the public key, too.

Generate the fingerprint

The wallet SDK has a built in function to generate the fingerprint:
import { TopologyController } from '@canton-network/wallet-sdk'

export default async function () {
    const publicKey = 'your-public-key-here'
    // static method call
    return TopologyController.createFingerprintFromPublicKey(publicKey)
}
this can be used to determine the unique party id beforehand or recompute the fingerprint based on the public key.

Generating the topology transactions

When onboarding using external signing, multiple topology transactions are required to be generated and signed. This is because both the keyHolder (the party) and the node (the validator) need to agree on the hosting relationship. The three transactions that needs to be generated are:
  • `PartyToParticipant`: This transaction indicates that the party agrees to be hosted by the participant (validator).
  • `ParticipantToParty`: This transaction indicates that the participant (validator) agrees to host the party.
  • `KeyToParty`: This transaction indicates that the key (public key) is associated with the party.
Once all the transactions are built they can be combined into a single hash and submitted as part of a single signature. The wallet SDK has helper functions to generate these transactions:
import {
    WalletSDKImpl,
    TopologyController,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
        topologyFactory: localNetTopologyDefault, // or use your specific configuration
    })

    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

    const { publicKey, privateKey } = TopologyController.createNewKeyPair()
    //partyHint is optional but recommended to make it easier to identify the party
    const partyHint = 'my-wallet-1'

    return await sdk.userLedger?.generateExternalParty(privateKey, partyHint)
}

Decoding the topology transactions

Sometimes converting the topology transactions to human readable json might be needed, for this you can use the decodeTopologyTx function:
import { TopologyTransaction } from '@canton-network/core-ledger-proto'
import {
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTopologyDefault,
    TopologyController,
    WalletSDKImpl,
    LedgerController,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
        topologyFactory: localNetTopologyDefault, // or use your specific configuration
    })

    await sdk.connect()
    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

    const { publicKey, privateKey } = TopologyController.createNewKeyPair()
    //partyHint is optional but recommended to make it easier to identify the party
    const partyHint = 'my-wallet-1'

    const generateExternalPartyResponse =
        await sdk.userLedger!.generateExternalParty(publicKey, partyHint)

    generateExternalPartyResponse!.topologyTransactions!.map((topologyTx) =>
        TopologyTransaction.toJson(
            LedgerController.toDecodedTopologyTransaction(topologyTx)
        )
    )
}

Sign multi-hash

Since the topology transactions need to be submitted together the combined hash needs to be signed. The wallet SDK has a helper function to sign the combined hash:
import { createKeyPair, signTransactionHash } from '@canton-network/wallet-sdk'

export default async function () {
    const preparedParty = EXISTING_TOPOLOGY
    const keys = createKeyPair()

    signTransactionHash(preparedParty.multiHash, keys.privateKey)
}

Submit the topology transactions

Once the signature is generated, the topology transactions can be submitted to the validator. The wallet SDK has a helper function to submit the transactions:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
        topologyFactory: localNetTopologyDefault, // or use your specific configuration
    })
    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

    const preparedParty = {
        transactions: [], // array of topology transactions
        multiHash: 'the-combined-hash',
        publicKeyFingerprint: 'your-namespace-here',
        partyId: 'your-party-id-here',
    }
    const signature = 'your-signed-hash-here'

    return sdk.userLedger?.allocateExternalParty(signature, preparedParty)
}

Multi-hosting a party

Since only relevant data is shared between validator nodes, and nodes don’t contain all data, backup and recovery are important. Another important aspect is to prevent having a validator being a single source of failure, this can be handled on a party basis by doing multi hosting. Multi hosting of a party means replication of all the information related to that party onto multiple validators, this can either be multiple validators run by the same entity (most common case for wallets) or even validators run by different entities in case of malicious actors. To facilitate multi-hosting we simply need to extend partyToParticipant and ParticipantToParty to include new validators. This requires sourcing signed transaction from the validators the client is interested in being hosted on. The below script allows you (by using the SDK) to host a single party on both app-user and app-provider validators.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetTokenStandardDefault,
    createKeyPair,
    localValidatorDefault,
    localNetStaticConfig,
    UpdatesResponse,
    CommandsCompletionsStreamResponse,
} from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import { v4 } from 'uuid'

const logger = pino({ name: '06-external-party-setup', level: 'info' })

// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const sdk = new WalletSDKImpl().configure({
    logger,
    authFactory: localNetAuthDefault,
    ledgerFactory: localNetLedgerDefault,
    topologyFactory: localNetTopologyDefault,
    tokenStandardFactory: localNetTokenStandardDefault,
    validatorFactory: localValidatorDefault,
})

logger.info('SDK initialized')

await sdk.connect()
logger.info('Connected to ledger')

const multiHostedPartyKeyPair = createKeyPair()
const singleHostedPartyKeyPair = createKeyPair()
const multiHostedKeyPairWithObserverParticipant = createKeyPair()

await sdk.connectAdmin()
await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

const authTokenProvider = sdk.authTokenProvider

const alice = await sdk.userLedger?.signAndAllocateExternalParty(
    singleHostedPartyKeyPair.privateKey,
    'alice'
)
logger.info(
    { partyId: alice?.partyId! },
    'created single hosted party to get synchronzerId'
)
await sdk.setPartyId(alice?.partyId!)

const multiHostedParticipantEndpointConfig = [
    {
        url: new URL('http://127.0.0.1:3975'),
        accessTokenProvider: authTokenProvider,
    },
]

logger.info('multi host party starting...')

const multiHostedParty = await sdk.userLedger?.signAndAllocateExternalParty(
    multiHostedPartyKeyPair.privateKey,
    'bob',
    1,
    multiHostedParticipantEndpointConfig
)

logger.info(multiHostedParty, 'multi hosted party succeeded!')

await sdk.setPartyId(multiHostedParty?.partyId!)

const commandsCompletionsEvents: CommandsCompletionsStreamResponse = []
const commandsCompletionsController = new AbortController()
const subscribeToCommandsMultiHostedParty = (async () => {
    try {
        const stream = sdk.userLedger?.subscribeToCompletions({
            beginOffset: 0,
        })
        for await (const completion of stream!) {
            logger.debug(
                completion,
                'received command completion update for multi hosted party'
            )
            commandsCompletionsEvents.push(
                completion as CommandsCompletionsStreamResponse
            )
            if (commandsCompletionsController.signal.aborted) break
        }
    } catch (err) {
        if (!commandsCompletionsController.signal.aborted) throw err
    }
})()

subscribeToCommandsMultiHostedParty

logger.info('Create ping command')
const createPingCommand = sdk.userLedger?.createPingCommand(
    multiHostedParty!.partyId!
)

logger.info('Prepare command submission for ping create command')

const pingCommandResponse = await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    createPingCommand,
    multiHostedPartyKeyPair.privateKey,
    v4()
)
logger.info(pingCommandResponse, 'ping command response')

const multiHostedPartyWithObservingParticipant =
    await sdk.userLedger?.signAndAllocateExternalParty(
        multiHostedKeyPairWithObserverParticipant.privateKey,
        'jon',
        1,
        [],
        [
            {
                url: new URL('http://127.0.0.1:3975'),
                accessTokenProvider: authTokenProvider,
            },
        ]
    )
logger.info(
    multiHostedPartyWithObservingParticipant,
    'created party with an observing participant'
)

await sdk.setPartyId(multiHostedPartyWithObservingParticipant?.partyId!)

const events: UpdatesResponse[] = []
const controller = new AbortController()

const subscribeToPingUpdates = (async () => {
    try {
        const stream = sdk.userLedger?.subscribeToUpdates({
            templateIds: [
                '#canton-builtin-admin-workflow-ping:Canton.Internal.Ping:Ping',
            ],
        })
        for await (const update of stream!) {
            events.push(update as UpdatesResponse)
            if (controller.signal.aborted) break
        }
    } catch (err) {
        if (!controller.signal.aborted) throw err
    }
})()

subscribeToPingUpdates

const createPingCommand2 = sdk.userLedger?.createPingCommand(
    multiHostedPartyWithObservingParticipant!.partyId!
)

const pingCommandResponse2 = await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    createPingCommand2,
    multiHostedKeyPairWithObserverParticipant.privateKey,
    v4()
)
logger.info(pingCommandResponse2, 'ping command response')

logger.info(commandsCompletionsEvents, 'commands completions events')

if (commandsCompletionsEvents.length === 0) {
    logger.error(
        'No command completion events received, something went wrong with the subscription'
    )
    commandsCompletionsController.abort()
    process.exit(1)
}

logger.info(events)
if (events.length === 0) {
    logger.error(
        'No events received, something went wrong with the subscription'
    )
    controller.abort()
    process.exit(1)
}
controller.abort()
commandsCompletionsController.abort()

process.exit(0)
Using the userLedgerControllers party allocation we only need to specify other validators the party is hosted on. The default is app-user, however if you do the onboarding using the topologyController legacy variant, then you would also need to supply configurations for the app-user.
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/finding-and-reading-data/index.rst Reviewers: Skip this section. Remove markers after final approval.

Finding and Reading Data

The wallet SDK primarily focus on an on-party basis interaction, therefore it is almost always required to define the party you are using. You can however create a party without defining a party, otherwise you have to set the party as done below:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })
    await sdk.connect()

    const myParty = global.EXISTING_PARTY_1
    await sdk.setPartyId(myParty)

    const myOtherParty = global.EXISTING_PARTY_2
    await sdk.setPartyId(myOtherParty)
}

Reading Available Parties

Reading all available parties to you can easily be done using the wallet SDK as shown in the example below, and the result is paginated. It’s worth noting that the call to read all available parties doesn’t use the the party and synchronizer fields therefore changing them has no effect on the result.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })

    await sdk.connect()

    await sdk.userLedger!.listWallets()
}

Reading Ledger End

A lot of different requests will take a ledger offset to ensure the requested time correlates with ledger time. A Validator does not have a block height since there is no total state replication. There are two values that correlate:
  • ledger time - this is the time the ledger chooses when computing a transaction prior to commit.
  • record time - this is the time assigned by the sequencer when registering the confirmation request.
Ledger time should be used for all operations in your local environment (that does not affect partners). When doing reconciliation for transactions with partners or other members of a synchronizer it is better to use record time. Ledger end can easily be derived from with the wallet SDK:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })
    await sdk.connect()

    await sdk.userLedger!.ledgerEnd()
}

Reading Active Contracts

Using the above ledger time we can figure out what the current state of all active contracts are. Contracts can be in two states - active and archived - which correlates to the UTXO mode of unspent and spent. Active contracts are contracts that are unspent and thereby can be used in new transactions or to exercise choices.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })
    await sdk.connect()

    const myParty = global.EXISTING_PARTY_1
    const offset = (await sdk.userLedger!.ledgerEnd()!).offset
    //we use holdings as an example here
    const myTemplateId = '#splice-amulet:Splice.Amulet:Amulet'

    await sdk.userLedger!.activeContracts({
        offset,
        parties: [myParty],
        templateIds: [myTemplateId], //this is optional for if you want to filter by template id
        filterByParty: true,
    })
}

Visualizing a Transaction

The Wallet SDK uses a transaction parsing transform a fully fledged transaction tree into human recognizable transaction view. The full code for the transaction parsing can be found at parser typescript class. The Wallet SDK uses this parser to transform all transaction tree interacted with into PrettyTransactions. for instance on the getTransactionById or listHoldingTransactions (Detailed here). The Transactions will have format:
export interface Transaction {
    updateId: string // unique updateId
    offset: number // the ledger offset (local validator)
    recordTime: string // time recorded on the synchronizer (use this if needed to compare with another ledger)
    synchronizerId: string // the synchronizer the transaction happened on
    events: TokenStandardEvent[] // event representing all the changes caused by the transaction
}
A single transaction can contain multiple events (deposits and withdrawals are considered events). In order to figure out the on chain transaction it is required to iterate over all the events. The events have the format:
export interface TokenStandardEvent {
    label: Label // used to identify the type of transaction
    lockedHoldingsChange: HoldingsChange // all the changes to locked holdings
    lockedHoldingsChangeSummary: HoldingsChangeSummary // summary of above changes
    unlockedHoldingsChange: HoldingsChange // all the changes to unlocked holdings
    unlockedHoldingsChangeSummary: HoldingsChangeSummary // summary of above changes
    transferInstruction: TransferInstructionView | null // any pending transfer instructions
}
below you can have a look at different event types and how to potentially visualize the transaction for a client
Here is an example on how a “tap” event looks like (Performing tap):
{
    "updateId": "1220a8d78d06461abd045813491f9997a1bcf2f29d4c2a9afadeb89616998201b40a",
    "offset": 1313,
    "recordTime": "2025-10-14T02:11:45.485840Z",
    "synchronizerId": "global-domain::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
    "events": [
        {
            "label": {
                "burnAmount": "0",
                "mintAmount": "2000000",
                "type": "Mint",
                "tokenStandardChoice": null,
                "reason": "tapped faucet",
                "meta": {
                    "values": {}
                }
            },
            "unlockedHoldingsChange": {
                "creates": [
                    {
                        "amount": "2000000.0000000000",
                        "instrumentId": {
                            "admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
                            "id": "Amulet"
                        },
                        "contractId": "00cee8d2659d5966962fbda321aae358092eafbb162d46f2639a8da0688ef3ee8aca11122096aeb27e0fa9c03a3209fda9db88e4e67a2ba1509f094bdd82a38d844ca65305",
                        "owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
                        "meta": {
                            "values": {
                                "amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
                                "amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
                            }
                        },
                        "lock": null
                    }
                ]
            },
            "unlockedHoldingsChangeSummary": {
                "numOutputs": 1,
                "outputAmount": "2000000",
                "amountChange": "2000000"
            },
            "transferInstruction": null
        }
    ]
}
The tap gives a nice and simple view some key values to look at. Using the label we can quickly gage what is happening:
"label": {
    "burnAmount": "0", // how much was burned
    "mintAmount": "2000000", // how much was minted
    "type": "Mint", // event type
    "tokenStandardChoice": null, // no token standard choice
    "reason": "tapped faucet", // reason
    "meta": {
        "values": {} // any other meta data value
    }
}
For a “tap” event we don’t have any locked holding changes, however we do have an unlocked create event:
"unlockedHoldingsChange": {
    // we have one create event
    // if utxos what spend this would be an archive instead
    "creates": [
        {
            // amount on the utxo
            "amount": "2000000.0000000000",
            // instrument information
            "instrumentId": {
                "admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
                "id": "Amulet"
            },
            // the contract id of the new utxo
            "contractId": "00cee8d2659d5966962fbda321aae358092eafbb162d46f2639a8da0688ef3ee8aca11122096aeb27e0fa9c03a3209fda9db88e4e67a2ba1509f094bdd82a38d844ca65305",
            // owner of the utxo
            "owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
            // any meta data
            "meta": {
                "values": {
                    "amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
                    "amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
                }
            },
            // lock if applicable
            "lock": null
        }
    ]
}
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/preparing-and-signing-transactions/index.rst Reviewers: Skip this section. Remove markers after final approval.

Preparing and Signing Transactions Using External Party

High-level Signing Process

The basic steps of preparing and signing a transaction using an external party are as follows:
  1. Creating a command - You start by simply creating a command.
  2. Preparing the transaction - You send the command to the blockchain RPC, offered by your node, to prepare the transaction.
  3. Validating the transaction - You inspect the transaction and decide whether to sign it.
  4. Signing the transaction - Once validated, you sign the transaction hash using your private key (typically with ECDSA/EdDSA).
  5. Submitting the transaction - You submit the signed transaction to be executed.
  6. Observing the transaction - You observe the blockchain until the transaction is committed.
In the examples below, the SDK examples use the Pint app which comes pre-installed with the validator and the cURL examples show the underlying HTTP requests using Canton Coin following a token standard transfer.

How do I quickly execute a Ping?

Below shows how to quickly execute a ping command against yourself on a running Splice LocalNet:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    createKeyPair,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'
import { pino } from 'pino'

const logger = pino({ name: '03-ping-localnet', level: 'info' })

// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const sdk = new WalletSDKImpl().configure({
    logger: logger,
    authFactory: localNetAuthDefault,
    ledgerFactory: localNetLedgerDefault,
    topologyFactory: localNetTopologyDefault,
})

logger.info('SDK initialized')

await sdk.connect()
logger.info('Connected to ledger')

const wallets = await sdk.userLedger?.listWallets()

logger.info(wallets, 'user Wallets')

const keyPair = createKeyPair()
await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

logger.info('generated keypair')
const allocatedParty = await sdk.userLedger?.signAndAllocateExternalParty(
    keyPair.privateKey
)
await sdk.setPartyId(allocatedParty!.partyId)

logger.info('Create ping command')
const createPingCommand = sdk.userLedger?.createPingCommand(
    allocatedParty!.partyId!
)

logger.info('Prepare command submission for ping create command')
const prepareResponse = await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    createPingCommand,
    keyPair.privateKey,
    v4()
)

Creating a Command

Commands are the intents of an user on the validator, there are two kinds of commands: CreateCommand and ExerciseCommand. The CreateCommand is used to create a new implementation of a template with the given arguments and can result in one or more new contracts being created. The ExerciseCommand takes an existing contract and exercises a choice on it, which also can result in new contracts being created. In the Canton Network, it is often necessary to need to include input data when creating commands which needs to be read from the ledger. For example, which UTXOs to include in a transfer. This is private data which you read from your own node. It’s also often necessary to include contextual information in a transfer. For example, information about a particular asset which you don’t get from your own node - you get from an API provided by the asset issuer. See here for more information. The general process for forming a transaction is:
  1. Call your own node’s RPC to get the current ledger end (think “latest block”)
  2. Call your own node’s RPC to get relevant private data at ledger end (e.g. wallet’s holdings)
  3. Call app/token specific APIs to get context information (e.g. mining round contracts)
  4. Assemble the data into the full command using the OpenAPI/JSON or gRPC schemas.
In the examples below, the SDK example uses the Token Standards inside the a validator to create a simple transfer command. The transfer command is sent to a recipient party who can then exercise accept or reject on the created contract (thereby archiving it). In the cURL example, we show the steps above gaining information from a validator and context information from the Canton Coin scan API. The Wallet SDK allow us to build such a command easily:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    await sdk.connect()
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const sender = global.EXISTING_PARTY_1
    const receiver = global.EXISTING_PARTY_2
    const instrumentAdminPartyId = global.INSTRUMENT_ADMIN_PARTY

    const [transferCommand, disclosedContracts] =
        await sdk.tokenStandard!.createTransfer(
            sender,
            receiver,
            '100',
            {
                instrumentId: 'Amulet',
                instrumentAdmin: instrumentAdminPartyId,
            },
            [],
            'memo-ref'
        )
}

Preparing the Transaction

Now that we have a command we need to prepare the transaction by calling a node’s RPC API which will return an unsigned transaction. It must be a validator which hosts the party initiating the transaction as private information is needed to construct the transaction. This is unlike other chains where you construct the transaction fully offline using an SDK. A transaction is a collection of commands that are atomic, meaning that either all commands succeed or none of them do. Note: contractId’s are pinned as part of prepare step, the execution of the transfer will only go succeed if the contractId’s haven’t been archived between preparation and execution steps. To prepare a transaction we need to send the commands to the ledger.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const sender = global.EXISTING_PARTY_1
    const receiver = global.EXISTING_PARTY_2
    const instrumentAdminPartyId = global.INSTRUMENT_ADMIN_PARTY

    await sdk.connect()
    await sdk.setPartyId(sender)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const [transferCommand, disclosedContracts] =
        await sdk.tokenStandard!.createTransfer(
            sender,
            receiver,
            '100',
            {
                instrumentId: 'Amulet',
                instrumentAdmin: instrumentAdminPartyId,
            },
            [],
            'memo-ref'
        )

    const transaction = await sdk.userLedger?.prepareSubmission(
        transferCommand, //the prepared ping command
        v4(), //a unique deduplication id for this transaction
        disclosedContracts //contracts that needs to be disclosed in our to execute the command
    )
}
The return type is an unsigned transaction if the combination of the commands are possible, otherwise an error is returned. The transaction can then be visualised and signed by the party.

Validating the Transaction

The result from the prepare step is an encoded protobuf message and easily decoded and inspected to go through a policy engine, for example. The transaction is returned alongside with the hash that needs to be signed. If the validator is not controlled by you, then it might be a good idea to validate that the transaction is what you expect it to be. You can use the Wallet SDK to visualize the transaction as described in the Visualizing a transaction section. On top of visualizing the transaction, it’s also important to compute the transaction hash yourself and confirm that it matches the hash of the transaction provided by the validator from the prepare step. The hash can be computed using the Wallet SDK:
import { TopologyController } from '@canton-network/wallet-sdk'

export default async function () {
    const transaction = global.PREPARED_TRANSACTION

    TopologyController.createTransactionHash(transaction.preparedTransaction!)
}
You can then compare the hash with the transaction.preparedTransactionHash to ensure they match.

Signing the Transaction

Once the transaction is validated, the hash retrieved from the prepare step can be signed using the private key of the party. Below shows an example in the Wallet SDK and using cURL commands:
import {
    createKeyPair,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    signTransactionHash,
    WalletSDKImpl,
} from '@canton-network/wallet-sdk'

export default async function () {
    const keys = createKeyPair()
    const transaction = EXISTING_TOPOLOGY

    signTransactionHash(transaction.multiHash, keys.privateKey)
}

Submitting the Transaction

Once the transaction is signed, it can be executed on the validator. You can observe completions by seeing the committed transactions. If they don’t appear on your ledger, you are guaranteed some response, and you can keep retrying; signed transactions are idempotent. Finality usually takes 3-10s.
import {
    localNetAuthDefault,
    localNetLedgerDefault,
    signTransactionHash,
    WalletSDKImpl,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })

    const myParty = global.EXISTING_PARTY_1
    const transaction = global.PREPARED_TRANSACTION
    const keys = global.EXISTING_PARTY_1_KEYS

    await sdk.connect()
    await sdk.setPartyId(myParty)

    const signature = signTransactionHash(
        transaction.preparedTransactionHash,
        keys.privateKey
    )

    await sdk.userLedger!.executeSubmission(
        transaction,
        signature,
        keys.publicKey,
        v4()
    )
}

Observing the Transaction

There are two ways to observe the transaction you have submitted. You can either:
  1. continuously monitor holdings changes using token standard history parser.
  2. use WaitFor to get the updateId and retrieve the transaction:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const sender = global.EXISTING_PARTY_1
    const senderKey = global.EXISTING_PARTY_1_KEYS.privateKey
    const instrumentAdminPartyId = global.INSTRUMENT_ADMIN_PARTY

    const receiver = global.EXISTING_PARTY_2

    await sdk.connect()
    await sdk.setPartyId(sender)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const [transferCommand, disclosedContracts2] =
        await sdk.tokenStandard!.createTransfer(
            sender,
            receiver,
            '100',
            {
                instrumentId: 'Amulet',
                instrumentAdmin: instrumentAdminPartyId,
            },
            [],
            'memo-ref'
        )

    //we use the AndWaitFor to get a completion result
    const completionResult = await sdk.userLedger?.prepareSignExecuteAndWaitFor(
        transferCommand,
        senderKey,
        v4(),
        disclosedContracts2
    )

    const transaction = await sdk.tokenStandard!.getTransactionById(
        completionResult!.updateId
    )
}

How to use the SDK to Offline sign a Transaction

The SDK exposes functionality that can be used in an offline environment to sign and validate transactions the below script shows an entire interaction between Alice and Bob with signing happening in an offline environment and online environment that performs the prepare and submit.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetTokenStandardDefault,
    createKeyPair,
    localNetStaticConfig,
    signTransactionHash,
    TopologyController,
} from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import { v4 } from 'uuid'

const onlineLogger = pino({ name: '08-online-localnet', level: 'info' })
const offlineLogger = pino({ name: '08-offline-localnet', level: 'info' })
// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const onlineSDK = new WalletSDKImpl().configure({
    logger: onlineLogger,
    authFactory: localNetAuthDefault,
    ledgerFactory: localNetLedgerDefault,
    topologyFactory: localNetTopologyDefault,
    tokenStandardFactory: localNetTokenStandardDefault,
})

onlineLogger.info(
    '===================== CONNECTING ONLINE SDK ====================='
)

await onlineSDK.connect()
onlineLogger.info('Connected to ledger')
await onlineSDK.connectAdmin()
onlineLogger.info('Connected as admin')
await onlineSDK.connectTopology(
    localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL
)
onlineLogger.info(
    `Connected to topology: ${localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL}`
)
onlineSDK.tokenStandard?.setTransferFactoryRegistryUrl(
    localNetStaticConfig.LOCALNET_REGISTRY_API_URL
)
onlineLogger.info(
    `defined registry url: ${localNetStaticConfig.LOCALNET_REGISTRY_API_URL}`
)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
offlineLogger.info(
    '===================== OFFLINE KEY GENERATION ====================='
)

const keyPairSender = createKeyPair()
offlineLogger.info(
    `Created sender key pair with public key: ${keyPairSender.publicKey}`
)
const keyPairReceiver = createKeyPair()
offlineLogger.info(
    `Created receiver key pair with public key: ${keyPairReceiver.publicKey}`
)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '===================== PREPARING ONBOARDING ====================='
)

const senderPrepared = await onlineSDK.userLedger?.generateExternalParty(
    keyPairSender.publicKey,
    'alice'
)

if (!senderPrepared) {
    throw new Error('Failed to prepare sender onboarding')
}

onlineLogger.info(
    `Prepared sender onboarding with multi hash: ${senderPrepared!.multiHash}`
)
const receiverPrepared = await onlineSDK.userLedger?.generateExternalParty(
    keyPairReceiver.publicKey,
    'bob'
)

if (!receiverPrepared) {
    throw new Error('Failed to prepare receiver onboarding')
}

onlineLogger.info(
    `Prepared receiver onboarding with multi hash: ${receiverPrepared!.multiHash}`
)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
offlineLogger.info(
    '===================== OFFLINE ONBOARDING SIGNING ====================='
)

const recomputedSenderHash = await TopologyController.computeTopologyTxHash(
    senderPrepared.topologyTransactions!
)

if (recomputedSenderHash !== senderPrepared.multiHash) {
    throw new Error(
        'Recomputed sender hash does not match prepared combined hash'
    )
}

const senderSigned = signTransactionHash(
    senderPrepared.multiHash,
    keyPairSender.privateKey
)
offlineLogger.info(`Signed sender onboarding hash: ${senderSigned}`)

const recomputedReceiverHash = await TopologyController.computeTopologyTxHash(
    receiverPrepared.topologyTransactions!
)

if (recomputedReceiverHash !== receiverPrepared.multiHash) {
    throw new Error(
        'Recomputed receiver hash does not match prepared multi hash'
    )
}

const receiverSigned = signTransactionHash(
    receiverPrepared.multiHash,
    keyPairReceiver.privateKey
)
offlineLogger.info(`Signed receiver onboarding hash: ${receiverSigned}`)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '===================== SUBMITTING ONBOARDING ====================='
)

const senderParty = await onlineSDK.userLedger?.allocateExternalParty(
    senderSigned,
    senderPrepared
)

onlineLogger.info(`created sender: ${senderParty!.partyId}`)

const receiverParty = await onlineSDK.userLedger?.allocateExternalParty(
    receiverSigned,
    receiverPrepared
)

onlineLogger.info(`created receiver: ${receiverParty!.partyId}`)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '===================== SENDER TAP (PREPARE) ====================='
)
await onlineSDK.setPartyId(senderParty!.partyId)

const instrumentAdminPartyId =
    (await onlineSDK.tokenStandard?.getInstrumentAdmin()) || ''

const [tapCommand, disclosedContracts] =
    await onlineSDK.tokenStandard!.createTap(senderParty!.partyId, '2000000', {
        instrumentId: 'Amulet',
        instrumentAdmin: instrumentAdminPartyId,
    })

const preparedTap = await onlineSDK.userLedger?.prepareSubmission(
    tapCommand,
    v4(),
    disclosedContracts
)

onlineLogger.info(
    `Prepared tap with hash: ${preparedTap!.preparedTransactionHash}`
)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
offlineLogger.info(
    '===================== OFFLINE TAP SIGNING ====================='
)

const recomputedTapHash = await TopologyController.createTransactionHash(
    preparedTap!.preparedTransaction!
)

if (recomputedTapHash !== preparedTap!.preparedTransactionHash) {
    throw new Error('Recomputed tap hash does not match prepared tap hash')
}

const signedTapHash = signTransactionHash(
    preparedTap!.preparedTransactionHash!,
    keyPairSender.privateKey
)
offlineLogger.info(`Signed tap hash: ${signedTapHash}`)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info('===================== SUBMITTING TAP =====================')

await onlineSDK.userLedger?.executeSubmissionAndWaitFor(
    preparedTap!,
    signedTapHash,
    keyPairSender.publicKey,
    v4()
)

onlineLogger.info('Tap completed')

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '===================== SENDER TRANSFER (PREPARE) ====================='
)

const [transferCommand, disclosedContracts2] =
    await onlineSDK.tokenStandard!.createTransfer(
        senderParty!.partyId,
        receiverParty!.partyId,
        '100',
        {
            instrumentId: 'Amulet',
            instrumentAdmin: instrumentAdminPartyId,
        },
        [],
        'memo-ref'
    )

const preparedTransfer = await onlineSDK.userLedger?.prepareSubmission(
    transferCommand,
    v4(),
    disclosedContracts2
)

onlineLogger.info(
    `Prepared transfer with hash: ${preparedTransfer!.preparedTransactionHash}`
)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
offlineLogger.info(
    '===================== OFFLINE TRANSFER SIGNING ====================='
)

const recomputedTransactionHash =
    await TopologyController.createTransactionHash(
        preparedTransfer!.preparedTransaction!
    )

if (recomputedTransactionHash !== preparedTransfer!.preparedTransactionHash) {
    throw new Error(
        'Recomputed transfer hash does not match prepared transfer hash'
    )
}

const signedTransferHash = signTransactionHash(
    preparedTransfer!.preparedTransactionHash!,
    keyPairSender.privateKey
)

offlineLogger.info(`Signed transfer hash: ${signedTransferHash}`)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '====================== SUBMITTING TRANSFER ====================='
)

await onlineSDK.userLedger?.executeSubmissionAndWaitFor(
    preparedTransfer!,
    signedTransferHash,
    keyPairSender.publicKey,
    v4()
)

onlineLogger.info('Transfer submitted')

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '===================== ACCEPT TRANSFER (PREPARE) ====================='
)
await onlineSDK.setPartyId(receiverParty!.partyId)

const pendingOffers =
    await onlineSDK.tokenStandard?.fetchPendingTransferInstructionView()

if (pendingOffers?.length !== 1) {
    throw new Error(
        `Expected exactly one pending transfer instruction, but found ${pendingOffers?.length}`
    )
}

onlineLogger.info(`Found pending offer: ${pendingOffers[0].contractId}`)

const pendingOffer = pendingOffers[0]
const [acceptTransferCommand, disclosedContracts3] =
    await onlineSDK.tokenStandard!.exerciseTransferInstructionChoice(
        pendingOffer.contractId,
        'Accept'
    )

const preparedAccept = await onlineSDK.userLedger?.prepareSubmission(
    acceptTransferCommand,
    v4(),
    disclosedContracts3
)

onlineLogger.info(
    `Prepared accept with hash: ${preparedAccept!.preparedTransactionHash}`
)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
offlineLogger.info(
    '===================== OFFLINE ACCEPT SIGNING ====================='
)

const recomputedAcceptHash = await TopologyController.createTransactionHash(
    preparedAccept!.preparedTransaction!
)

if (recomputedAcceptHash !== preparedAccept!.preparedTransactionHash) {
    throw new Error(
        'Recomputed accept hash does not match prepared accept hash'
    )
}

const signedAcceptHash = signTransactionHash(
    preparedAccept!.preparedTransactionHash!,
    keyPairReceiver.privateKey
)

offlineLogger.info(`Signed accept hash: ${signedAcceptHash}`)

//this can go to fast, so sleep to make logging clearer
await new Promise((resolve) => setTimeout(resolve, 100))
onlineLogger.info(
    '===================== SUBMITTING ACCEPT ====================='
)

await onlineSDK.userLedger?.executeSubmissionAndWaitFor(
    preparedAccept!,
    signedAcceptHash,
    keyPairReceiver.publicKey,
    v4()
)

onlineLogger.info('Accepted transfer instruction')
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/signing-transactions-from-dapps/index.rst Reviewers: Skip this section. Remove markers after final approval.

Signing Transactions from third party dApps

A normal flow on blockchain applications is to have dApps that interact with the blockchain on the clients behalf, these flows usually require the user to sign transactions that the dApp prepares and submit it. To faciliate this in Canton it is required that the prepared transaction is sent to the wallet for signing. An easy way of supporting this is to expose a dApp API (OpenRPC spec can be found here: https://github.com/canton-network/wallet-gateway/blob/main/api-specs/openrpc-dapp-api.json ). The specs are in OpenRPC to conform with traditional standards like for ethereum. A client can provide access to a Wallet Providers dApp API by either embedding a wallet provider in the dApp or by connecting to an external wallet provider via a browser extension or other means. Then the dApp is able to funnel transactions through to the wallet provider for signing.

Receiving a Transaction

A dApp would usually call the prepareExecute endpoint or the prepareExecuteAndWait endpoint. In both cases the Wallet Provider would prepare, sign and submit the transaction to the ledger. You can prepare the incoming transaction using the Wallet SDK:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    signTransactionHash,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })

    const preparedCommand = global.PREPARED_COMMAND
    const keys = global.EXISTING_PARTY_1_KEYS
    const myParty = global.EXISTING_PARTY_1

    await sdk.connect()
    await sdk.setPartyId(myParty)

    const preparedTransaction = await sdk.userLedger!.prepareSubmission(
        preparedCommand, //the incoming command
        v4() //a unique deduplication id for this transaction
    )

    const signature = signTransactionHash(
        preparedTransaction!.preparedTransactionHash ?? '',
        keys.privateKey
    )

    //if client calls ``prepareExecute`` then this is how they would call ``execute``
    await sdk.userLedger!.executeSubmission(
        preparedTransaction!,
        signature,
        keys.publicKey,
        v4()
    )
}

Reading and Visualising the Transaction

It is important when integrating with third party dApps to showcase the User exactly what is being signed. Once the signature is applied the transaction can be considered valid (and executed). The easiest would be to create a visualizer that takes a JSON representation of the transaction. The Json for a prepared transaction (before signature is applied) can be obtained using the Wallet SDK:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    decodePreparedTransaction,
    PreparedTransaction,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
    })
    await sdk.connect()

    const myParty = global.EXISTING_PARTY_1
    const preparedCommand = global.PREPARED_COMMAND

    await sdk.setPartyId(myParty)
    const preparedTransaction = await sdk.userLedger!.prepareSubmission(
        preparedCommand, //the incoming command
        v4() //a unique deduplication id for this transaction
    )

    const decodedTransaction = decodePreparedTransaction(
        preparedTransaction!.preparedTransaction!
    )

    PreparedTransaction.toJson(decodedTransaction)

    // Here you can use your choice of JSON visualizer
}
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/token-standard/index.rst Reviewers: Skip this section. Remove markers after final approval.

Token Standard

The Wallet SDK support performing basic token standard operations, these are exposed through the sdk.tokenStandard a complete overview of the underlying integration can be found here and the CIP is defined here.

How do i quickly perform a transfer between two parties?

The below performs a 2-step transfer between Alice and Bob and expose their holdings:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetTokenStandardDefault,
    createKeyPair,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import { v4 } from 'uuid'

const logger = pino({ name: '04-token-standard-localnet', level: 'info' })

// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const sdk = new WalletSDKImpl().configure({
    logger,
    authFactory: localNetAuthDefault,
    ledgerFactory: localNetLedgerDefault,
    topologyFactory: localNetTopologyDefault,
    tokenStandardFactory: localNetTokenStandardDefault,
})

logger.info('SDK initialized')

await sdk.connect()
logger.info('Connected to ledger')

const keyPairSender = createKeyPair()
const keyPairReceiver = createKeyPair()

await sdk.connectAdmin()
await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

const sender = await sdk.userLedger?.signAndAllocateExternalParty(
    keyPairSender.privateKey,
    'alice'
)
logger.info(`Created party: ${sender!.partyId}`)
await sdk.setPartyId(sender!.partyId)

const receiver = await sdk.userLedger?.signAndAllocateExternalParty(
    keyPairReceiver.privateKey,
    'bob'
)
logger.info(`Created party: ${receiver!.partyId}`)

await sdk.userLedger
    ?.listWallets()
    .then((wallets) => {
        logger.info(wallets, 'Wallets:')
    })
    .catch((error) => {
        logger.error({ error }, 'Error listing wallets')
    })

sdk.tokenStandard?.setTransferFactoryRegistryUrl(
    localNetStaticConfig.LOCALNET_REGISTRY_API_URL
)
const instrumentAdminPartyId =
    (await sdk.tokenStandard?.getInstrumentAdmin()) || ''

const [tapCommand, disclosedContracts] = await sdk.tokenStandard!.createTap(
    sender!.partyId,
    '2000000',
    {
        instrumentId: 'Amulet',
        instrumentAdmin: instrumentAdminPartyId,
    }
)

await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    tapCommand,
    keyPairSender.privateKey,
    v4(),
    disclosedContracts
)

const utxos = await sdk.tokenStandard?.listHoldingUtxos(false)

logger.info(utxos, 'List Available Token Standard Holding UTXOs')

await sdk.tokenStandard
    ?.listHoldingTransactions()
    .then((transactions) => {
        logger.info(transactions, 'Token Standard Holding Transactions:')
    })
    .catch((error) => {
        logger.error(
            { error },
            'Error listing token standard holding transactions:'
        )
    })

logger.info('Creating transfer transaction')

const [transferCommand, disclosedContracts2] =
    await sdk.tokenStandard!.createTransfer(
        sender!.partyId,
        receiver!.partyId,
        '100',
        {
            instrumentId: 'Amulet',
            instrumentAdmin: instrumentAdminPartyId,
        },
        utxos?.map((t) => t.contractId),
        'memo-ref'
    )

await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    transferCommand,
    keyPairSender.privateKey,
    v4(),
    disclosedContracts2
)
logger.info('Submitted transfer transaction')

await sdk.setPartyId(receiver!.partyId)

const pendingInstructions =
    await sdk.tokenStandard?.fetchPendingTransferInstructionView()

const transferCid = pendingInstructions?.[0].contractId!

const [acceptTransferCommand, disclosedContracts3] =
    await sdk.tokenStandard!.exerciseTransferInstructionChoice(
        transferCid,
        'Accept'
    )

await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    acceptTransferCommand,
    keyPairReceiver.privateKey,
    v4(),
    disclosedContracts3
)

logger.info('Accepted transfer instruction')

{
    await sdk.setPartyId(sender!.partyId)
    const aliceHoldings = await sdk.tokenStandard?.listHoldingTransactions()
    logger.info(aliceHoldings, '[ALICE] holding transactions')

    await sdk.setPartyId(receiver!.partyId)
    const bobHoldings = await sdk.tokenStandard?.listHoldingTransactions()
    logger.info(bobHoldings, '[BOB] holding transactions')
}

logger.info(
    await sdk.userLedger!.activeContracts({
        offset: (await sdk.userLedger!.ledgerEnd()).offset,
        parties: [sender!.partyId, receiver!.partyId],
        filterByParty: true,
    }),
    'Active Contracts (without specified templateIds/interfaceIds)'
)

Listing holdings (UTXO’s)

Canton uses created and archived events to determine the state of the ledger. This correlates to how UTXO’s are handled on other blockchains like Bitcoin. This means that at any point in time you can retrieve all your active contracts with the interface ‘Holding’ to see all assets you posses across different instruments.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const myParty = global.EXISTING_PARTY_1

    await sdk.connect()
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )
    await sdk.setPartyId(myParty)

    // takes an option boolean whether to include locked holdings
    // default is 'true' and in this case utxos locked in a 2-step transfer (awaiting accept or reject)
    // is included in the output
    const utxos = await sdk.tokenStandard?.listHoldingUtxos(false)
}
the above script can safely be used to determine used in a transfer, if you provide no boolean value or true then you need to filter out the locked ones manually.

Listing holding transactions

In order to stream transaction events as they happen on ledger the listHoldingTransactions endpoint can be used. This takes two ledger offset and gives an overview of all token standard transactions that have happened between. It also returns a nextOffset that can be used when calling the endpoint again. This will allow you to easily ensure you do not receive any transaction twice and you are only querying the transactions that have happened after.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const myParty = global.EXISTING_PARTY_1

    await sdk.connect()
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )
    await sdk.setPartyId(myParty)

    let startLedger = 0
    let step = 10

    const holdings = await sdk.tokenStandard!.listHoldingTransactions(
        startLedger,
        step
    )

    //increment steps to get more holdings if there are more
}
to quickly convert the stream into deposit and withdrawal you can use this function:
function convertToTransaction(pt: Transaction, associatedParty: string): object[] {
    return pt.events.flatMap((event) => {
        if (event.label.type === 'TransferIn') {
            return [{
                updateId: pt.updateId,
                recordTime: pt.recordTime,
                from: event.label.sender,
                to: associatedParty,
                amount: Number(event.unlockedHoldingsChangeSummary.amountChange),
                instrumentId: 'Amulet', //hardcoded instrumentId from local net
                fee: Number(event.label.burnAmount),
                memo: event.label.reason,
            }];
        } else if (event.label.type === 'TransferOut') {
            const label = event.label
            return event.label.receiverAmounts.map((receiverAmount: any) => ({
                updateId: pt.updateId,
                recordTime: pt.recordTime,
                from: associatedParty,
                to: receiverAmount.receiver,
                amount: Number(receiverAmount.amount),
                instrumentId: 'Amulet', //hardcoded instrumentId from local net
                fee: Number(label.burnAmount),
                memo: label.meta.reason,
            }));
        } else {
            return [];
        }
    });
}

Performing a Tap on DevNet or LocalNet

When writing scripts and setup it is important to have funds present, this can be very tedious on blockchains. Therefor most blockchains support some form of a faucet (that allows to receive a small amount of funds to play with). On canton we allow the tap method that is only present on DevNet (or LocalNet), by using this you can stock funds to easily attempt some of the CC transfer flows:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const myParty = global.EXISTING_PARTY_1
    const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey
    const instrumentAdminPartyId = global.INSTRUMENT_ADMIN_PARTY

    await sdk.connect()
    await sdk.setPartyId(myParty)
    sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const [tapCommand, disclosedContracts] = await sdk.tokenStandard!.createTap(
        myParty,
        '2000000',
        {
            instrumentId: 'Amulet',
            instrumentAdmin: instrumentAdminPartyId,
        }
    )

    await sdk.userLedger?.prepareSignExecuteAndWaitFor(
        tapCommand,
        myPrivateKey,
        v4(),
        disclosedContracts
    )
}
this is an important pre-requisite for the creating of transfer in your script.

Creating a transfer

In order to create a simple transfer you can use the createTransfer on the token standard. Then like any other operation you can use the prepareSubmission endpoint, sign the returned hash and finally executeSubmission.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    await sdk.connect()
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const sender = global.EXISTING_PARTY_1
    const receiver = global.EXISTING_PARTY_2
    const instrumentAdminPartyId = global.INSTRUMENT_ADMIN_PARTY

    const [transferCommand, disclosedContracts] =
        await sdk.tokenStandard!.createTransfer(
            sender,
            receiver,
            '100',
            {
                instrumentId: 'Amulet',
                instrumentAdmin: instrumentAdminPartyId,
            },
            [],
            'memo-ref'
        )
}

UTXO management and locked funds

The default script for creating a transfer above uses automated utxo selection, the automatic being to simply select all utxo’s. In a more professional way, you would want to carefully pick which utxo’s you would like to use as input for your transfers, alongside you might also want to define a custom expiration time for when the transaction should automatically expire.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    await sdk.connect()
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const sender = global.EXISTING_PARTY_1
    const receiver = global.EXISTING_PARTY_2
    const instrumentAdminPartyId = global.INSTRUMENT_ADMIN_PARTY

    await sdk.setPartyId(sender)
    const utxos = await sdk.tokenStandard?.listHoldingUtxos(false)

    //let's assume we have 3 utxos of 100,50,25
    const utxosToUse = utxos!.filter((t) => t.interfaceViewValue.amount != '50') //we filter out the 50, since we want to send 125

    //we only want the recipient to have 1 minute to accept
    const expireDate = new Date(Date.now() + 60 * 1000)
    const [transferCommand, disclosedContracts] =
        await sdk.tokenStandard!.createTransfer(
            sender,
            receiver,
            '125',
            {
                instrumentId: 'Amulet',
                instrumentAdmin: instrumentAdminPartyId,
            },
            utxosToUse.map((t) => t.contractId),
            'memo-ref',
            expireDate
        )
}
if we call sdk.tokenStandard?.listHoldingUtxos(false) then it will show 1 utxo of 50 (then one we excluded). if we call sdk.tokenStandard?.listHoldingUtxos(true) then it will show all 3 utxos (100 and 25 both will have a lock).

2-step transfer vs 1-step transfer

The default behavior for all tokens are a 2-step transfer, this matches how funds are usually transferred in TradFi, however this is counter-intuitive in the blockchain world. Canton Coin supports setting up a “Transfer Pre-approval”, this allows a party to designate that he wants to auto-accept all incoming transfer, giving a similar behavior of the blockchain world.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
    localValidatorDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
        validatorFactory: localValidatorDefault,
    })

    const myParty = global.EXISTING_PARTY_1
    const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey

    await sdk.connect()
    await sdk.setPartyId(myParty)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const validatorOperatorParty = await sdk.validator?.getValidatorUser()

    const instrumentAdminPartyId =
        (await sdk.tokenStandard?.getInstrumentAdmin()) || ''

    await new Promise((res) => setTimeout(res, 5000))

    const transferPreApprovalProposal =
        await sdk.userLedger?.createTransferPreapprovalCommand(
            validatorOperatorParty!, //operator party
            myParty, //party to auto accept for
            instrumentAdminPartyId //admin of the instrument
        )

    await sdk.userLedger?.prepareSignAndExecuteTransaction(
        [transferPreApprovalProposal],
        myPrivateKey,
        v4()
    )
}

Accepting or rejecting a 2-step transfer

If no Transfer pre-approval have been set up, then it is required to fetch incoming transfer instructions and consume either the Accept or Reject choice, this can be done easily using the Wallet SDK.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const myParty = global.EXISTING_PARTY_1

    await sdk.connect()
    await sdk.setPartyId(myParty)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    //this returns a list of all transfer instructions, you can then accept or reject them
    const pendingInstructions =
        await sdk.tokenStandard!.fetchPendingTransferInstructionView()
}
the above give a list of pending transfer instructions, you can then exercise the accept or reject choice on them:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
    localValidatorDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const myParty = global.EXISTING_PARTY_2
    const myPrivateKey = global.EXISTING_PARTY_2_KEYS.privateKey
    const Reject = true

    await sdk.connect()
    await sdk.setPartyId(myParty)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const myPendingTransaction =
        await sdk.tokenStandard!.fetchPendingTransferInstructionView()
    const myPendingTransactionCid = myPendingTransaction[0].contractId
    if (Reject) {
        //reject the transaction
        const [rejectTransferCommand, disclosedContracts] =
            await sdk.tokenStandard!.exerciseTransferInstructionChoice(
                myPendingTransactionCid,
                'Reject'
            )

        const rejectCommandId =
            await sdk.userLedger?.prepareSignAndExecuteTransaction(
                rejectTransferCommand,
                myPrivateKey,
                v4(),
                disclosedContracts
            )
    } else {
        //accept the transaction
        const [acceptTransferCommand, disclosedContracts] =
            await sdk.tokenStandard!.exerciseTransferInstructionChoice(
                myPendingTransactionCid,
                'Accept'
            )

        const acceptCommandId =
            await sdk.userLedger?.prepareSignAndExecuteTransaction(
                acceptTransferCommand,
                myPrivateKey,
                v4(),
                disclosedContracts
            )
    }
}

Withdrawing a 2-step transfer before it gets accepted

Apart from accepting or rejecting a transfer instruction, it is also possible for the sender to withdraw the offer, thereby retrieving the locked funds.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
    localValidatorDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
        validatorFactory: localValidatorDefault,
    })

    const myParty = global.EXISTING_PARTY_1
    const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey

    await sdk.connect()
    await sdk.setPartyId(myParty)
    sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const myPendingTransaction =
        await sdk.tokenStandard!.fetchPendingTransferInstructionView()
    const myPendingTransactionCid = myPendingTransaction[0].contractId

    //withdraw the transaction
    const [withdrawTransferCommand, disclosedContracts] =
        await sdk.tokenStandard!.exerciseTransferInstructionChoice(
            myPendingTransactionCid,
            'Withdraw'
        )

    const withdrawCommandId =
        await sdk.userLedger?.prepareSignAndExecuteTransaction(
            withdrawTransferCommand,
            myPrivateKey,
            v4(),
            disclosedContracts
        )
}

How do i quickly setup transfer preapproval?

It is worth nothing that using the validator operator party as the providing party causes the transfer pre-approval to auto-renew. The below script setup transfer preapproval for Bob and performs a 1-step transfer from Alice to Bob:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetTokenStandardDefault,
    createKeyPair,
    localValidatorDefault,
    localNetStaticConfig,
    LedgerController,
} from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import { v4 } from 'uuid'

const logger = pino({ name: '05-external-party-setup', level: 'info' })

// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const sdk = new WalletSDKImpl().configure({
    logger,
    authFactory: localNetAuthDefault,
    ledgerFactory: localNetLedgerDefault,
    topologyFactory: localNetTopologyDefault,
    tokenStandardFactory: localNetTokenStandardDefault,
    validatorFactory: localValidatorDefault,
})

logger.info('SDK initialized')

await sdk.connect()
logger.info('Connected to ledger')

const keyPairSender = createKeyPair()
const keyPairReceiver = createKeyPair()

await sdk.connectAdmin()
await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

const sender = await sdk.userLedger?.signAndAllocateExternalParty(
    keyPairSender.privateKey,
    'alice'
)
logger.info(`Created party: ${sender!.partyId}`)
await sdk.setPartyId(sender!.partyId)

sender?.topologyTransactions!.map((topologyTx) => {
    const decodedTx = LedgerController.toDecodedTopologyTransaction(topologyTx)
    logger.info(decodedTx)
})

const receiver = await sdk.userLedger?.signAndAllocateExternalParty(
    keyPairReceiver.privateKey,
    'bob'
)
logger.info(`Created party: ${receiver!.partyId}`)

await sdk.userLedger
    ?.listWallets()
    .then((wallets) => {
        logger.info(wallets, 'Wallets:')
    })
    .catch((error) => {
        logger.error({ error }, 'Error listing wallets')
    })

sdk.tokenStandard?.setTransferFactoryRegistryUrl(
    localNetStaticConfig.LOCALNET_REGISTRY_API_URL
)

await sdk.setPartyId(receiver?.partyId!)
const validatorOperatorParty = await sdk.validator?.getValidatorUser()

const instrumentAdminPartyId =
    (await sdk.tokenStandard?.getInstrumentAdmin()) || ''

await new Promise((res) => setTimeout(res, 5000))

logger.info('creating transfer preapproval proposal')

await sdk.setPartyId(validatorOperatorParty!)
await sdk.tokenStandard?.createAndSubmitTapInternal(
    validatorOperatorParty!,
    '20000000',
    {
        instrumentId: 'Amulet',
        instrumentAdmin: instrumentAdminPartyId,
    }
)

await sdk.setPartyId(receiver?.partyId!)

const transferPreApprovalProposal =
    await sdk.userLedger?.createTransferPreapprovalCommand(
        validatorOperatorParty!,
        receiver?.partyId!,
        instrumentAdminPartyId
    )

await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    [transferPreApprovalProposal],
    keyPairReceiver.privateKey,
    v4()
)

logger.info('transfer pre approval proposal is created')

await sdk.setPartyId(sender?.partyId!)

const [tapCommand, disclosedContracts] = await sdk.tokenStandard!.createTap(
    sender!.partyId,
    '20000000',
    {
        instrumentId: 'Amulet',
        instrumentAdmin: instrumentAdminPartyId,
    }
)

await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    tapCommand,
    keyPairSender.privateKey,
    v4(),
    disclosedContracts
)

const utxos = await sdk.tokenStandard?.listHoldingUtxos()
logger.info(utxos, 'List Token Standard Holding UTXOs')

await sdk.tokenStandard
    ?.listHoldingTransactions()
    .then((transactions) => {
        logger.info(transactions, 'Token Standard Holding Transactions:')
    })
    .catch((error) => {
        logger.error(
            { error },
            'Error listing token standard holding transactions:'
        )
    })

logger.info('Creating transfer transaction')

const [transferCommand, disclosedContracts2] =
    await sdk.tokenStandard!.createTransfer(
        sender!.partyId,
        receiver!.partyId,
        '100',
        {
            instrumentId: 'Amulet',
            instrumentAdmin: instrumentAdminPartyId,
        },
        [],
        'memo-ref'
    )

await sdk.userLedger?.prepareSignExecuteAndWaitFor(
    transferCommand,
    keyPairSender.privateKey,
    v4(),
    disclosedContracts2
)
logger.info('Submitted transfer transaction')

await sdk.setPartyId(validatorOperatorParty!)

const validatorFeatureAppRights =
    await sdk.tokenStandard!.grantFeatureAppRightsForInternalParty()

logger.info(
    validatorFeatureAppRights,
    `Featured App Rights for validator ${validatorOperatorParty}`
)

{
    await sdk.setPartyId(sender!.partyId)
    const aliceHoldings = await sdk.tokenStandard?.listHoldingTransactions()
    logger.info(aliceHoldings, '[ALICE] holding transactions')

    await sdk.setPartyId(receiver!.partyId)
    const bobHoldings = await sdk.tokenStandard?.listHoldingTransactions()
    logger.info(bobHoldings, '[BOB] holding transactions')
    const transferPreApprovalStatus =
        await sdk.tokenStandard?.getTransferPreApprovalByParty(
            receiver!.partyId,
            'Amulet'
        )
    logger.info(transferPreApprovalStatus, '[BOB] transfer preapproval status')
}

How to renew or cancel a transfer preapproval

If you have used the validator operator party as the provider, then it will automatically renew the transfer preapproval approximately 20 days before expiry, however there are cases where you would like to perform the preapproval renewal manually:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })

    const myParty = global.EXISTING_PARTY_WITH_PREAPPROVAL
    const validatorOperatorParty = global.VALIDATOR_OPERATOR_PARTY

    await sdk.connect()
    await sdk.setPartyId(myParty)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    //Fetch the existing preapproval
    const preapproval =
        await sdk.tokenStandard!.waitForPreapprovalFromScanProxy(
            myParty,
            'Amulet'
        )
    const [renewCmd, disclosedContractsRenew] =
        await sdk.tokenStandard!.createRenewTransferPreapproval(
            preapproval!.contractId,
            preapproval!.templateId,
            validatorOperatorParty!
        )

    //Sign and execute the above command
}
You can also deploy a secondary transfer preapproval, however this means that there are simply two preapprovals instead of it replacing the existing. If you have accidentally created a transfer preapproval that you dont want to keep you can perform a cancel instead:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    localNetTokenStandardDefault,
    localValidatorDefault,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
        validatorFactory: localValidatorDefault,
    })

    const myParty = global.EXISTING_PARTY_WITH_PREAPPROVAL

    await sdk.connect()
    await sdk.setPartyId(myParty)
    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        localNetStaticConfig.LOCALNET_REGISTRY_API_URL
    )

    const validatorOperatorParty = await sdk.validator!.getValidatorUser()

    const preapproval =
        await sdk.tokenStandard!.waitForPreapprovalFromScanProxy(
            myParty,
            'Amulet'
        )
    const [renewCmd, disclosedContractsRenew] =
        await sdk.tokenStandard!.createCancelTransferPreapproval(
            preapproval!.contractId,
            preapproval!.templateId,
            validatorOperatorParty!
        )

    //Sign and execute the above command
}
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/wallet-sdk-configuration/index.rst Reviewers: Skip this section. Remove markers after final approval.

Wallet SDK Configuration

If you have already played around with the wallet SDK you might have come across snippets like:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetTokenStandardDefault,
    localValidatorDefault,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: localNetLedgerDefault,
        topologyFactory: localNetTopologyDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
        validatorFactory: localValidatorDefault,
    })
}
This is the default config that can be used in combination with a non-altered Localnet running instance. However as soon as you need to migrate your script, code and deployment to a different environment these default configurations are no longer viable to use. In those cases creating custom factories for each controller is needed. Here is a template that you can use when setting up your own custom connectivity configuration:
import {
    WalletSDKImpl,
    LedgerController,
    TopologyController,
    ValidatorController,
    TokenStandardController,
    AuthTokenProvider,
    localNetAuthDefault,
} from '@canton-network/wallet-sdk'

// @disable-snapshot-test
export default async function () {
    const myLedgerFactory = (
        userId: string,
        authTokenProvider: AuthTokenProvider
    ) => {
        return new LedgerController(
            userId,
            new URL('http://my-json-ledger-api'),
            undefined,
            false,
            authTokenProvider
        )
    }
    // topology controller is deprecated in favor of using ledgerController
    // it is stil supported however for backwards compatibility

    // const myTopologyFactory = (
    //     userId: string,
    //     authTokenProvider: AuthTokenProvider,
    //     synchronizerId: string
    // ) => {
    //     return new TopologyController(
    //         'my-grpc-admin-api',
    //         new URL('http://my-json-ledger-api'),
    //         userId,
    //         synchronizerId,
    //         undefined,
    //         authTokenProvider
    //     )
    // }
    const myValidatorFactory = (
        userId: string,
        authTokenProvider: AuthTokenProvider
    ) => {
        return new ValidatorController(
            userId,
            new URL('http://my-validator-app-api'),
            authTokenProvider
        )
    }

    const myTokenStandardFactory = (
        userId: string,
        authTokenProvider: AuthTokenProvider
    ) => {
        return new TokenStandardController(
            userId,
            new URL('http://my-json-ledger-api'),
            new URL('http://my-validator-app-api'),
            undefined, //previously used if you used access token
            authTokenProvider
        )
    }

    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault,
        ledgerFactory: myLedgerFactory,
        //topologyFactory: myTopologyFactory,
        validatorFactory: myValidatorFactory,
        tokenStandardFactory: myTokenStandardFactory,
    })
    await sdk.connect()
    await sdk.connectAdmin()
    //an alternative here is the use the synchronizer directly like
    //await sdk.connectTopology('global-domain::22200...')
    await sdk.connectTopology(new URL('http://my-scan-proxy-api'))

    await sdk.tokenStandard!.setTransferFactoryRegistryUrl(
        new URL('http://my-registry-api')
    )
}

How do I validate my configurations?

Knowing if you are using the correct url and port can be daunting, here is a few curl and gcurl commands you can use to validate against an expected output my-json-ledger-api can be identified with curl http://${my-json-ledger-api}/v2/version it should produce a json that looks like
{
   "version":"3.3.0-SNAPSHOT",
   "features":{
      "experimental":{
         "staticTime":{
            "supported":false
         },
         "commandInspectionService":{
            "supported":true
         }
      },
      "userManagement":{
         "supported":true,
         "maxRightsPerUser":1000,
         "maxUsersPageSize":1000
      },
      "partyManagement":{
         "maxPartiesPageSize":10000
      },
      "offsetCheckpoint":{
         "maxOffsetCheckpointEmissionDelay":{
            "seconds":75,
            "nanos":0,
            "unknownFields":{
               "fields":{

               }
            }
         }
      }
   }
}
the fields may vary based on your configuration.
the topology controller is deprecated so the below section for my-grpc-admin-api is not needed anymore.
my-grpc-admin-api can be identified with grpcurl -plaintext ${my-grpc-admin-api} list it should produce an output like
com.digitalasset.canton.admin.health.v30.StatusService
com.digitalasset.canton.admin.participant.v30.EnterpriseParticipantReplicationService
com.digitalasset.canton.admin.participant.v30.PackageService
com.digitalasset.canton.admin.participant.v30.ParticipantInspectionService
com.digitalasset.canton.admin.participant.v30.ParticipantRepairService
com.digitalasset.canton.admin.participant.v30.ParticipantStatusService
com.digitalasset.canton.admin.participant.v30.PartyManagementService
com.digitalasset.canton.admin.participant.v30.PingService
com.digitalasset.canton.admin.participant.v30.PruningService
com.digitalasset.canton.admin.participant.v30.ResourceManagementService
com.digitalasset.canton.admin.participant.v30.SynchronizerConnectivityService
com.digitalasset.canton.admin.participant.v30.TrafficControlService
com.digitalasset.canton.connection.v30.ApiInfoService
com.digitalasset.canton.crypto.admin.v30.VaultService
com.digitalasset.canton.time.admin.v30.SynchronizerTimeService
com.digitalasset.canton.topology.admin.v30.IdentityInitializationService
com.digitalasset.canton.topology.admin.v30.TopologyAggregationService
com.digitalasset.canton.topology.admin.v30.TopologyManagerReadService
com.digitalasset.canton.topology.admin.v30.TopologyManagerWriteService
grpc.reflection.v1alpha.ServerReflection
the list might differed based on you canton configuration, the most important part is TopologyManagerReadService & TopologyManagerWriteService my-validator-app-api can be identified with curl ${api}/version it should produce an output like
{"version":"0.4.15","commit_ts":"2025-09-05T11:38:13Z"}
my-scan-proxy-api is a api inside the validator api and can be defined as ${my-validator-app-api}/v0/scan-proxy. my-registry-api is the registry for the token you want to use, for Canton Coin you can use my-scan-proxy-api, however for any other token standard token it is required to source the api from a reputable source.

Configuring Auth Controller

By default the localNetAuthDefault uses these defined values:
userId = 'ledger-api-user'
adminId = 'ledger-api-user'
audience = 'https://canton.network.global'
unsafeSecret = 'unsafe'
this produces a self-signed HMAC auth token using “unsafe” for signing.
The value for some of the audiences in localnet would have to be adjusted to match “https://canton.network.global”. This is specifically the LEDGER_API_AUTH_AUDIENCE & VALIDATOR_AUTH_AUDIENCE.
When upgrading your setup from a localnet setup to a production or client facing environment then it might make more sense to add proper authentication to the ledger api and other services. The community contributions include okta and keycloak OIDC. These can easily be configured for the SDK using a custom clientCredentialOAuthController
import {
    WalletSDKImpl,
    localNetLedgerDefault,
    localNetTokenStandardDefault,
    AuthController,
    ClientCredentialOAuthController,
} from '@canton-network/wallet-sdk'
import { Logger } from 'pino'

export default async function () {
    const participantId = 'my-participant-id'
    const myOAuthUrl = new URL('https://my-oauth-url')
    const myOAuthController = (logger?: Logger): AuthController => {
        const controller = new ClientCredentialOAuthController(
            //your oauth server
            `http://${myOAuthUrl.href}/.well-known/openid-configuration`,
            logger
        )

        //oAuth M2M token for client id and client secret
        controller.userId = 'your-client-id'
        controller.userSecret = 'your-client-secret'
        // these are only needed if you intend to use admin only functions
        // these can be different from your userId and userSecret
        // if they are the same you can supply it twice
        //controller.adminId = 'your-client-id'
        //controller.adminSecret = 'your-client-secret'
        controller.audience = `https://daml.com/jwt/aud/participant/${participantId}`
        controller.scope = 'openid daml_ledger_api offline_access'

        return controller
    }

    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: myOAuthController,
        ledgerFactory: localNetLedgerDefault,
        tokenStandardFactory: localNetTokenStandardDefault,
    })
}
However since it follows a simple interface, you can build your own implementation of it if you have unique requirements:
export interface AuthController {
    /** gets an auth context correlating to the non-admin user provided.
     */
    getUserToken(): Promise<AuthContext>

    /** gets an auth context correlating to the admin user provided.
     */
    getAdminToken(): Promise<AuthContext>
    userId: string | undefined
}
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/traffic/index.rst Reviewers: Skip this section. Remove markers after final approval.

Traffic

Below is a high-level summary of the Sycnrhonizer Traffic Fees page in the Splice Validator documentation. For more detail on point, it’s advised to read the that documenation.

Traffic

  • Traffic fees are paid at the validator node level, not the party level.
  • Every validator has a traffic balance at the global synchronizer level.
    • Traffic is measured in bytes.
    • A trickle rate of free traffic is provided to validator nodes every 10 minutes (each mining round).
  • Traffic is deducted from your validator node’s traffic balance every time your node sends a message to the synchronizer. Traffic is charged for:
    • Broadcasting a transaction - this is where the bulk of the traffic fees will be paid.
    • Sending consensus messages for transactions a validator is involved in.
  • If your node runs out of traffic it is unable to transact. It’ll recover by itself thanks to the free trickle rate. However, you can buy more traffic. See the next section.

Getting more traffic

  • Traffic is obtained by burning Canton Coin and it is always pre-purchased.
  • The conversion Canton Coin <> Bytes can be derived from on-chain parameters.
    • Super Validators publish an on-chain conversion CC <> USD.
    • Super Validators publish a traffic cost USD <> Bytes.
  • Anyone can burn Canton Coin to get traffic for any node.
    • You can buy your own traffic.
    • You can sign up with a service like the Denex Gas Station to buy your traffic.
  • The validator node has automation to keep traffic topped up. As long as you keep CC in your validator party, it’ll stay available. See here for how to configure automatic traffic purchases.

How to determine the traffic cost of a transaction?

Follow this FAQ entry in the Splice documentation.
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/tokenomics-and-rewards/index.rst Reviewers: Skip this section. Remove markers after final approval.

Tokenomics and Rewards

CC Rewards

  • The tokenomics operate on 10m “mining rounds”.
  • Every 10 minutes, different stakeholders of the network are rewarded with coupons which can be used to mint Canton Coin according to how much value they’ve brought to the network.
  • Coupons are rewarded to the Validator admin party.
  • All rewards awarded to a node’s local parties will be auto-minted by the node administrator party.
  • The validators automation is not able to mint the rewards for an external parties - the external party needs to delegate the ability for the validator admin party to mint their rewards on their behalf or manually mint the rewards themselves each round they receive rewards.
  • All rewards and coupons are mintable the follow mining round
  • If rewards are not redeemed then they are lost*
You can find more information about the tokenomics of Canton Coin here.

Ways of Obtaining Canton Coin Rewards

The tokenomics of the network give you options for obtaining Canton Coin:

Validator & Super Validator Liveness Rewards

Just for being online and growing the network, Canton Coin tokenomics enable validator operators to mint CC. Validators and Super Validators generate reward coupons that can be used to mint Canton Coins. The coupons are paid out to the validator adminstration party. For local parties onboarded to a validator, the validator application runs background automation to mint all activity records automatically. An external party signs transactions using a key they control. As a consequence, the validator automation is not able to perform minting for external parties. For external parties, automation needs to be developed to call AmuletRules_Transfer at least once per round with all activity records as inputs. You can find more information about the tokenomics of Canton Coin at https://docs.dev.sync.global/overview/overview.html#tokenomics. All rewards and coupons are mintable the follow mining round, if rewards are not redemed then they are lost

Validator Activeness Rewards

If you self-purchase traffic, you get a discount via these rewards. Application Rewards
  • Transactions which include Canton Coin and featured application transactions earn application rewards.
  • The percentage of Canton Coin awarded to applications is significant and will grow over time.
  • The current amount of CC awarded to applications can be seen in the ‘Canton Coin Reward Split By Role Over Time’ chart here.
Featured application activity markers
  • Applications which generate valuable activity for the canton network, and have ‘featured application’ status can earn more application rewards.
  • By qualifying as a Featured Application (apply here) and applying a FeaturedAppActivityMarker to a transaction it is marked and converted to reward coupons that can be redeemed.
  • A weighting is applied to each transaction in that Canton Coin minting round.
  • More weightings in a round equate to more application rewards.
  • Currently, featured apps receive many more rewards in Canton Coin than the average transaction costs in traffic fees.

Gaining Application Rewards as a Wallet/Custodian/Exchange

  • Request featured application status. Apply here.
    • On DevNet you can self-feature through the wallet UI.
Enabling Pre-approval / 1-step Transfers
  • One way that wallets can earn app rewards is by enabling direct / 1 step / pre-approval transfers
  • Setting up pre-approvals costs around $1 per 90 days per party
  • By enabling 1-step transfers your party is added as the operator party to incoming deposits
  • Therefore, when users deposit funds into your account you’ll receive rewards.
  • You can also mark transactions out of your wallet (that don’t go to parties which have 1-step transfers enabled) with your party as the operator part for that transaction
  • It’s anticipated that you will receive far more Canton Coin through rewards for pre-approval deposits and transfers than you pay in traffic fees, setting up pre-approvals and for creating parties.
  • Therefore:
    • You may not want to charge your users for traffic in the near term.
    • In the mid-to-long term the tokenomics may not support this model, so you may want to think about a charging strategy.
    • We still advise monitoring or even controlling the number of parties that a user can create so that you don’t end up with users creating too many parties and therefore cost.

Redeming Reward Coupons with External Party

To accept rewards with an external party you need to call AmuletRules_Transfer with the activity records as inputs. Featured Application rewards can be shared between multiple parties, this can be done by defining a list of benificiaries and give them a weighted amount of the total reward. The sum of all beneficiaries weight must be equal to 1.0. This results in separate coupons being generated for each beneficiary.
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/user-management/index.rst Reviewers: Skip this section. Remove markers after final approval.

User Management

The Wallet SDK has functionality for creating and managing user rights, by default when you are connecting it uses whichever user is defined in your auth-controller. If the user is an admin user on the ledger api they can be used to create other users and grant them rights.

How do I quickly setup canReadAsAnyParty and canExecuteAsAnyParty?

This script sets up three users alice, bob and master. master is given canReadAsAnyParty and canExecuteAsAnyParty and it shows proper access control by creating parties and ensuring that alice and bob can not see each others parties.
import {
    WalletSDKImpl,
    createKeyPair,
    localNetStaticConfig,
    AuthController,
    UnsafeAuthController,
} from '@canton-network/wallet-sdk'
import { Logger, pino } from 'pino'

const logger = pino({ name: '11-multi-user-setup', level: 'info' })

// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const operatorSDK = new WalletSDKImpl().configure({
    logger,
})

logger.info('Operator sets up users and primary parties')

await operatorSDK.connect()
await operatorSDK.connectAdmin()
await operatorSDK.connectTopology(
    localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL
)

const aliceInternal =
    await operatorSDK.adminLedger!.allocateInternalParty('alice')
const bobInternal = await operatorSDK.adminLedger!.allocateInternalParty('bob')
const masterUserInternal =
    await operatorSDK.adminLedger!.allocateInternalParty('master-user')

const aliceUser = await operatorSDK.adminLedger!.createUser(
    'alice-user',
    aliceInternal
)
const bobUser = await operatorSDK.adminLedger!.createUser(
    'bob-user',
    bobInternal
)

const masterUser = await operatorSDK.adminLedger!.createUser(
    'master-user',
    masterUserInternal
)

await operatorSDK.adminLedger!.grantMasterUserRights(masterUser.id, true, true)

logger.info(
    `Created alice user: ${aliceUser.id} with primary party (internal) ${aliceUser.primaryParty}`
)
logger.info(
    `Created bob user: ${bobUser.id} with primary party (internal) ${bobUser.primaryParty}`
)
logger.info(
    `Created master user: ${masterUser.id} with primary party (internal) ${masterUser.primaryParty}, with read as and execute as rights`
)

//create a SDK for each user with their own auth factory
const aliceSDK = new WalletSDKImpl().configure({
    logger,
    authFactory: (logger?: Logger): AuthController => {
        const controller = new UnsafeAuthController(logger)

        controller.userId = aliceUser.id
        controller.adminId = aliceUser.id
        controller.audience = 'https://canton.network.global'
        controller.unsafeSecret = 'unsafe'

        return controller
    },
})

await aliceSDK.connect()
await aliceSDK.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)
aliceSDK.tokenStandard?.setTransferFactoryRegistryUrl(
    localNetStaticConfig.LOCALNET_REGISTRY_API_URL
)

const bobSDK = new WalletSDKImpl().configure({
    logger,
    authFactory: (logger?: Logger): AuthController => {
        const controller = new UnsafeAuthController(logger)

        controller.userId = bobUser.id
        controller.adminId = bobUser.id
        controller.audience = 'https://canton.network.global'
        controller.unsafeSecret = 'unsafe'

        return controller
    },
})

await bobSDK.connect()
await bobSDK.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)
bobSDK.tokenStandard?.setTransferFactoryRegistryUrl(
    localNetStaticConfig.LOCALNET_REGISTRY_API_URL
)

const masterUserSDK = new WalletSDKImpl().configure({
    logger,
    authFactory: (logger?: Logger): AuthController => {
        const controller = new UnsafeAuthController(logger)

        controller.userId = masterUser.id
        controller.adminId = masterUser.id
        controller.audience = 'https://canton.network.global'
        controller.unsafeSecret = 'unsafe'

        return controller
    },
})

await masterUserSDK.connect()
await masterUserSDK.connectTopology(
    localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL
)
masterUserSDK.tokenStandard?.setTransferFactoryRegistryUrl(
    localNetStaticConfig.LOCALNET_REGISTRY_API_URL
)
logger.info('connected ledger only SDK for each user')

const aliceKeyPair = createKeyPair()
const bobKeyPair = createKeyPair()

const alice = await aliceSDK.userLedger?.signAndAllocateExternalParty(
    aliceKeyPair.privateKey,
    'alice'
)
logger.info(`Created party: ${alice!.partyId}`)
await aliceSDK.setPartyId(alice!.partyId)

const bob = await bobSDK.userLedger?.signAndAllocateExternalParty(
    bobKeyPair.privateKey,
    'bob'
)
logger.info(`Created party: ${bob!.partyId}`)
await bobSDK.setPartyId(bob!.partyId)

logger.info('alice and bob each create an external party')

const masterWalletView = await masterUserSDK.userLedger?.listWallets()

if (!masterWalletView?.find((p) => p === alice!.partyId)) {
    throw new Error('master user cannot see alice party')
}
if (!masterWalletView?.find((p) => p === bob!.partyId)) {
    throw new Error('master user cannot see bob party')
}

logger.info('master user can see both parties')

const aliceWalletView = await aliceSDK.userLedger?.listWallets()

if (aliceWalletView?.find((p) => p === bob!.partyId)) {
    throw new Error('alice user can see bob party')
}

if (!aliceWalletView?.find((p) => p === alice!.partyId)) {
    throw new Error('alice user cannot see alice party')
}

const bobWalletView = await bobSDK.userLedger?.listWallets()

if (bobWalletView?.find((p) => p === alice!.partyId)) {
    throw new Error('bob user can see alice party')
}

logger.info(
    'alice and bob have proper isolation and cannot see each others external parties'
)

//user management test

await bobSDK.userLedger?.grantRights([alice!.partyId])

const bobWalletViewAfterGrantRights = await bobSDK.userLedger?.listWallets()

if (!bobWalletViewAfterGrantRights?.find((p) => p === alice!.partyId)) {
    throw new Error('bob user cannot see alice party even with ReadAs rights')
}

Creating a new user

Creating a new user can be done using the adminLedger, this new user can then be granted rights or can create new parties as needed.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
        topologyFactory: localNetTopologyDefault, // or use your specific configuration
    })
    await sdk.connect()
    await sdk.connectAdmin()
    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

    const defaultParty = 'my-default-party'

    const newUser = await sdk.adminLedger!.createUser(
        'my-new-user',
        defaultParty
    )
}

ReadAs and ActAs limitations

Currently when allocating a new party we also grant ReadAs and ActAs rights for that party for the submitting user. This allows the user to do the normal flows involved like preparing transactions and executing those. There are performance issues if too many of these rights are assigned to the same user, in the case of a master user that is interacting on behalf of a client, then it might be more convenient to use CanReadAsAnyParty and CanExecuteAsAnyParty as described below. Here is how the method changes if you need to allocate a party without granting rights:
import {
    WalletSDKImpl,
    createKeyPair,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetStaticConfig,
    signTransactionHash,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
    })
    await sdk.connect()
    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)
    const key = createKeyPair()

    const preparedParty = await sdk.userLedger!.generateExternalParty(
        key.publicKey,
        'my-party'
    )
    const signedHash = signTransactionHash(
        preparedParty.multiHash,
        key.privateKey
    )

    const party = await sdk.userLedger!.allocateExternalParty(
        signedHash,
        preparedParty,
        false //do not grant user actAs and readAs for the party
    )
}

CanReadAsAnyParty

CanReadAsAnyParty gives a user full information about any party on the ledger, if a user is set up with this they will see: 1. All parties hosted on the ledger (multi-hosted and single hosted) 2. All transaction happening involving a party on the ledger 3. Prepare transactions on behalf of any party This will not grant information about parties hosted on other ledgers or their transactions.
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
        topologyFactory: localNetTopologyDefault, // or use your specific configuration
    })
    await sdk.connect()
    await sdk.connectAdmin()
    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

    const userId = 'ledger-api-user'

    //public async grantMasterUserRights(userId: string, canReadAsAnyParty: boolean, canExecuteAsAnyParty: boolean)
    await sdk.adminLedger!.grantMasterUserRights(userId, true, false)
}
The SDK automatically leverages this elevated permission for certain endpoints like listWallets.

CanExecuteAsAnyParty

CanExecuteAsAnyParty gives full execution rights for a party, this means that a user with these rights can submit transaction on behalf of a party hosted on the ledger. This does not give the user rights to move funds without a valid signature! The setup is similar to the `CanReadAsAnyParty`:
import {
    WalletSDKImpl,
    localNetAuthDefault,
    localNetLedgerDefault,
    localNetTopologyDefault,
    localNetStaticConfig,
} from '@canton-network/wallet-sdk'

export default async function () {
    // it is important to configure the SDK correctly else you might run into connectivity or authentication issues
    const sdk = new WalletSDKImpl().configure({
        logger: console,
        authFactory: localNetAuthDefault, // or use your specific configuration
        ledgerFactory: localNetLedgerDefault, // or use your specific configuration
        topologyFactory: localNetTopologyDefault, // or use your specific configuration
    })
    await sdk.connect()
    await sdk.connectAdmin()
    await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL)

    const userId = 'ledger-api-user'

    //public async grantMasterUserRights(userId: string, canReadAsAnyParty: boolean, canExecuteAsAnyParty: boolean)
    await sdk.adminLedger!.grantMasterUserRights(userId, true, true)
}
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/canton-coin-specific-considerations/index.rst Reviewers: Skip this section. Remove markers after final approval.

Canton Coin Specific Considerations

Handling Time-Bound Signatures (Canton Coin)

If your wallet infrastructure relies on offline signing, cold storage, or multi-party approval flows that take longer than 10–20 minutes, then this guide is for you.

The 10-Minute Signing Window

Canton Coin transactions operate on a strict 10-minute minting cycle. Unlike standard Daml transactions, Canton Coin transfers and acceptances must reference a specific OpenMiningRound contract to calculate network fees and rewards.
  • A new OpenMiningRound is created every 10 minutes.
  • The contract remains active for approximately 20 minutes (the current round + overlap).
  • The Problem: If you prepare a transaction referencing Round A, but you do not sign and submit it before Round A expires, the network will reject it.
Common Error: If your transaction exceeds this window, the API will return a 409 Conflict with the following error:
LOCAL_VERDICT_INACTIVE_CONTRACTS
There is one way of handle incoming transfers and another way to handle outgoing transfers, listed below.

Solution 1: Implement Pre-approvals for Incoming Transfers / Receiving Funds

Use Case: Your users need to receive Canton Coin, but you cannot sign a transaction within 10 minutes (e.g., due to cold storage of the receiver’s keys). The Fix: Enable 1-Step Transfers using Pre-approvals. Instead of signing every incoming transfer, the receiver signs a single, long-living TransferPreapproval contract. This authorizes the sending party (or a specific provider) to deposit funds immediately without requiring an interactive acceptance signature for every transaction. To do this, create a Splice.Wallet.TransferPreapproval contract. The guide on how to create the pre-approval contract in the Wallet SDK is here and the general information about Canton Coin Preapprovals is here. By implementing a preapproval contract the receiver doesn’t need to accept Canton Coin transfers sent to them as they are automatically accepted.

Solution 2: Use Command Delegation for Outgoing Transfers / Sending Funds

Using TransferCommand only works where the receiver has enabled pre-approvals for Canton Coin and the sending external party has been onboarded to the splice wallet using the Validator APIs. Parties set up using the validator APIs and not using these workarounds are subject to the 200 party limit described here.
Use Case: Your users need to send Canton Coin, but the signing process (e.g., institutional custody approval) takes hours. The Fix: Use Command Delegation - TransferCommand. Instead of signing the transfer transaction directly (which pins a short-lived Mining Round), the user signs a long-living instruction to transfer funds.

How it works

  1. User Signs Instruction: The user signs a transaction to create a Splice.ExternalPartyAmuletRules.TransferCommand contract.
    • This contract does not reference a mining round.
    • It can remain valid for up to 24 hours (or as defined by expiresAt).
  2. Delegated Execution: Once this command is on the ledger, a Super Validator (SV) or a delegate picks it up.
  3. Execution: The delegate executes the actual transfer. The delegate selects the current OpenMiningRound at the moment of execution, ensuring the transaction succeeds regardless of how long ago the user signed the instruction.
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/deposits-into-exchanges/index.rst Reviewers: Skip this section. Remove markers after final approval.

Sending Deposits to Exchanges

To enable deposits to be sent to specific user accounts at exchanges, an account identifier needs to be sent to the exchange along with the transfer information. In Canton, the “memo tag” pattern is implemented as follows.

Canton Coin Wallet

In the Canton Coin wallet, the “Description” field in the screenshot below must be used to communicate this account identifier in the format required by the exchange. For example: “AcmeExchange account: <exchangeInternalAccountId>”. Splice wallet UI

CN Token Standard Wallets

The token standard defines the splice.lfdecentralizedtrust.org/reason metadata key for the purpose of communicating a human-readable description for the transfer (see CIP-0056). Token standard wallets must provide a “Description” or “Reason” field analogous to the Canton Coin wallet, and store its value in the metadata field of the Transfer specification (code) when initiating a transfer. This is actually what the Canton Coin wallet does behind the scenes when initiating a Canton Coin transfer. Likewise when displaying an incoming transfer or the tx history for a transfer the content of splice.lfdecentralizedtrust.org/reason metadata key should be parsed and displayed, as done for example by the transaction history parser in the token standard CLI (docs). This allows exchanges to communicate a correlation-id for a redemption. Code sample for setting the right metadata field: see this change to the experimental token standard CLI to take the “reason” as command line argument and store it in the metadata field.

CN Token Registries

Token standard compliant registries must ensure that they pass the Transfer specification unchanged along when implementing multi-step transfers using the TransferInstruction interface (code).
This section was copied from existing reviewed documentation. Source: docs/wallet-integration-guide/src/usdcx-support/index.rst Reviewers: Skip this section. Remove markers after final approval.

USDCx Support for Wallets

Overview

Circle and Digital Asset have partnered to develop and implement a USDC token on Canton Network. This implementation requires users to send USDC on L1 chains (starting with Ethereum) to Circle’s xReserve contract which is then created as a USDC token on Canton Network. Conversely a withdrawal request for a USDC token on the Canton Network will result in the release of the asset on another chain. During the existence of the USDC token on Canton Network, it is available for use in financial transactions. USDC on Canton Network represents USDC locked by Circle on the original L1 chain. And this token on Canton Network, is in the form of a Canton Network Standard Token (CIP-56) as defined through the Canton Network Utilities service. Wallet providers and exchanges have three options for supporting USDCx on the Canton Network:
  1. Transfer & hold USDCx - Since USDCx is a token standard (CIP-56) compliant asset then as such any wallet that supports the token standard will have built in support for transfers and holding.
  2. Support xReserves deposits and withdrawals - Custom API integration is required for wallet providers and exchanges to support xReserve deposits and withdrawals to the utility-bridge daml models to / from their parties using the xReserves UI. Instructions for doing this are included in the section “Supporting xReserve Deposits and Withdrawals” below.
  3. Integrating the xReserve UI (Ethereum) into the wallet - To enable a full end-to-end experience for the user, a wallet can integrate against Ethereum directly for deposits on top of integrating point 2. To provide an example for doing this, the xReserves UI as well as open-sourced example scripts are available for reference. This demonstrates the 2 ethereum transactions that must be submitted to an ethereum node:
    • approve a USDC spending allowance.
    • depositToRemote to deposit USDC into the xReserve contract.

Supporting xReserve Deposits and Withdrawals

The required dar file can be found here There are 3 choices (API calls) a wallet will need to implement in order to fully support the xReserve:

MainNet Environment Variables

VariableValue
UTILITY_BACKEND_URLhttps://api.utilities.digitalasset.com
ADMIN_PARTY_IDdecentralized-usdc-interchain-rep::12208115f1e168dd7e792320be9c4ca720c751a02a3053c7606e1c1cd3dad9bf60ef
UTILITY_OPERATOR_PARTY_IDauth0_007c6643538f2eadd3e573dd05b9::12205bcc106efa0eaa7f18dc491e5c6f5fb9b0cc68dc110ae66f4ed6467475d7c78e
BRIDGE_OPERATOR_PARTY_IDBridge-Operator::1220c8448890a70e65f6906bd48d797ee6551f094e9e6a53e329fd5b2b549334f13f

TestNet Environment Variables

VariableValue
UTILITY_BACKEND_URLhttps://api.utilities.digitalasset-staging.com
ADMIN_PARTY_IDdecentralized-usdc-interchain-rep::122049e2af8a725bd19759320fc83c638e7718973eac189d8f201309c512d1ffec61
UTILITY_OPERATOR_PARTY_IDDigitalAsset-UtilityOperator::12202679f2bbe57d8cba9ef3cee847ac8239df0877105ab1f01a77d47477fdce1204
BRIDGE_OPERATOR_PARTY_IDBridge-Operator::12209d011ce250de439fefc35d16d1ab9d56fb99ccb24c18d798efb22352d533bcdb

Extracting Contract IDs and Disclosed Contracts

The utilities backend provides a Burn Mint Factory API Endpoint Endpoint:
$/api/utilities/v0/registry/burn-mint-instruction/v0/burn-mint-factory
Example request body:
{
    "instrumentId": {
        "admin": "${ADMIN_PARTY_ID}",
        "id": "USDCx"
    },
    "inputHoldingCids": "${HOLDING_CONTRACT_IDS_IF_WITHDRAWING}",
    "outputs": [
        {
            "owner": "${ADMIN_PARTY_ID}",
            "amount": "${AMOUNT_IN_DECIMAL}" // For minting, this is the amount to mint. For burning, this is the change amount.
        }
    ]
}

When you call the Burn Mint factory endpoint, the response contains the contract IDs and disclosed contracts you need for both minting and withdrawing. Note that these values can be cached to reduce api calls as these values change infrequently. As an example for extracting the required contexts and contracts from the response:
// Assume `response` is the parsed JSON from the API call
const choiceContext = response.httpResponse.body.choiceContext;

// Extract CONTEXT_CONTRACT_IDS
const values = choiceContext.choiceContextData.values;
const contextContractIds = {
    instrumentConfigurationCid: values["utility.digitalasset.com/instrument-configuration"].value,
    appRewardConfigurationCid: values["utility.digitalasset.com/app-reward-configuration"].value,
    featuredAppRightCid: values["utility.digitalasset.com/featured-app-right"].value,
};

// Extract FACTORY_CID
const factoryCid = response.httpResponse.body.factoryId;

// Extract DISCLOSED_CONTRACTS
const disclosedContracts = choiceContext.disclosedContracts;

Onboarding

To use the xReserve a party will first need to onboard to the bridge using the below: Example API call:
{
   "CreateCommand": {
       "templateId": "#utility-bridge-v0:Utility.Bridge.V0.Agreement.User:BridgeUserAgreementRequest",
       "createArguments": {
           "crossChainRepresentative": "${ADMIN_PARTY_ID}",
           "operator": "${UTILITY_OPERATOR_PARTY_ID}",
           "bridgeOperator": "${BRIDGE_OPERATOR_PARTY_ID}",
           "user": "${USER_PARTY_ID}",
           "instrumentId": {
               "admin": "${ADMIN_PARTY_ID}",
               "id": "USDCx"
           },
           "preApproval": false
       }
   }
}

Mint

Once a user deposits USDC into ethereum a DepositAttestation is created on the Canton network. In order for the recipient party to claim those funds they will need to call a choice to mint from the DepositAttestation: \#utility-bridge-v0:Utility.Bridge.V0.Attestation.Deposit:DepositAttestation Example API call:
{
    "commands": [
        {
            "ExerciseCommand": {
                "templateId": "#utility-bridge-v0:Utility.Bridge.V0.Agreement.User:BridgeUserAgreement",
                "contractId": "${BRIDGE_USER_AGREEMENT_CONTRACT_ID}",
                "choice": "BridgeUserAgreement_Mint",
                "choiceArgument": {
                    "depositAttestationCid": "${DEPOSIT_ATTESTATION_CID}",
                    "factoryCid": "${FACTORY_CID}",
                    "contextContractIds": "${CONTEXT_CONTRACT_IDS}"
                }
            }
        }
    ],
    "disclosedContracts": "${DISCLOSED_CONTRACTS}",
}

Withdraw

To withdraw from the Canton Network to Ethereum a user must burn the USDC on Canton. Specifying the:
  • destination domain id: Currently only Ethereum is supported (domain id of 0).
  • Amount: In Decimal to a max 6 decimal precision.
  • Destination recipient: a valid Ethereum address.
  • An optional reference. Empty Text field if not provided.
In addition the wallet will need to provide:
  • The available Holding contract Ids
  • A UUID as the requestId
Example API call:
{
    "commands": [
        {
            "ExerciseCommand": {
                "templateId": "#utility-bridge-v0:Utility.Bridge.V0.Agreement.User:BridgeUserAgreement",
                "contractId": "${BRIDGE_USER_AGREEMENT_CONTRACT_ID}",
                "choice": "BridgeUserAgreement_Burn",
                "choiceArgument": {
                "amount": "${AMOUNT_IN_DECIMAL}",
                "destinationDomain": "0",
                "destinationRecipient": "${ETHEREUM_ADDRESS}",
                    "holdingCids": "${HOLDING_CONTRACT_IDS}",
                "requestId": "${UUID_REQUEST_ID}",
                "reference": "",
                "factoryCid": "${FACTORY_CID}",
                    "contextContractIds": "${CONTEXT_CONTRACT_IDS}"
                }
            }
        }
    ],
    "disclosedContracts": "${DISCLOSED_CONTRACTS}",
}