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 Ping 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 {
    localNetStaticConfig,
    SDK,
    signTransactionHash,
} from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import { v4 } from 'uuid'
import {
    TOKEN_NAMESPACE_CONFIG,
    TOKEN_PROVIDER_CONFIG_DEFAULT,
    AMULET_NAMESPACE_CONFIG,
} from './utils/index.js'

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

const sdk = await SDK.create({
    auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
    ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
    token: TOKEN_NAMESPACE_CONFIG,
    amulet: AMULET_NAMESPACE_CONFIG,
})

const senderKeys = sdk.keys.generate()

const sender = await sdk.party.external
    .create(senderKeys.publicKey, {
        partyHint: 'v1-01-alice',
    })
    .sign(senderKeys.privateKey)
    .execute()

const senderFingerprint = await sdk.keys.fingerprint(senderKeys.publicKey)

logger.info({ sender, senderFingerprint }, 'Sender party representation:')

if (sender.publicKeyFingerprint !== senderFingerprint)
    throw Error('Inconsistent fingerprints')

const receiverKeys = sdk.keys.generate()

const receiverPartyCreation = sdk.party.external.create(
    receiverKeys.publicKey,
    {
        partyHint: 'v1-01-bob',
    }
)

const unsignedReceiver = await receiverPartyCreation.topology()

// external signing simulation
const receiverPartySignature = signTransactionHash(
    unsignedReceiver.multiHash,
    receiverKeys.privateKey
)

const signedReceiverParty = await receiverPartyCreation.execute(
    receiverPartySignature
)

logger.info({ signedReceiverParty }, 'Receiver party representation:')

const pingCommand = [
    {
        CreateCommand: {
            templateId:
                '#canton-builtin-admin-workflow-ping:Canton.Internal.Ping:Ping',
            createArguments: {
                id: v4(),
                initiator: sender.partyId,
                responder: sender.partyId,
            },
        },
    },
]

logger.info({ pingCommand }, 'Ping command to be submitted:')

await sdk.ledger
    .prepare({
        partyId: sender.partyId,
        commands: pingCommand,
        disclosedContracts: [],
    })
    .sign(senderKeys.privateKey)
    .execute({ partyId: sender.partyId })

logger.info('Ping command submitted with online signing')

/*
offline signing example
*/

const preparedPingCommand = sdk.ledger.prepare({
    partyId: sender.partyId,
    commands: pingCommand,
    disclosedContracts: [],
})

const { response: preparedPingCommandResponse } =
    await preparedPingCommand.toJSON()

logger.info({ preparedPingCommand }, 'Prepared ping command:')

/*
Note: The following code uses the @canton-network/core-signing-lib as the 'custodian' of the private key to sign the prepared transaction hash,
but in a real scenario, the signing could be done using any compatible signing mechanism, such as a hardware wallet or an external signing service.
*/
const signature = signTransactionHash(
    preparedPingCommandResponse.preparedTransactionHash,
    senderKeys.privateKey
)

const signed = sdk.ledger.fromSignature(preparedPingCommandResponse, signature)

await sdk.ledger.execute(signed, { partyId: sender.partyId })

logger.info('Ping command submitted with offline signing')

const [amuletTapCommand, amuletTapDisclosedContracts] = await sdk.amulet.tap(
    sender.partyId,
    '10000'
)

const result = await sdk.ledger
    .prepare({
        partyId: sender.partyId,
        commands: amuletTapCommand,
        disclosedContracts: amuletTapDisclosedContracts,
    })
    .sign(senderKeys.privateKey)
    .execute({ partyId: sender.partyId })

const senderUtxos = await sdk.token.utxos.list({ partyId: sender.partyId })

const tapTransaction = await sdk.token.transactionsById({
    updateId: result.updateId,
    partyId: sender.partyId,
})

const mintEvent = tapTransaction.events.find(
    (tokenStandardEvent) =>
        tokenStandardEvent.label.type === 'Mint' &&
        tokenStandardEvent.unlockedHoldingsChange.creates.find(
            (h) => h.amount === '10000.0000000000'
        )
)

if (mintEvent) {
    logger.info('Found token standard event with type Mint')
} else {
    throw new Error(`Couldn't find tap transaction by updateId`)
}
const senderAmuletUtxos = senderUtxos.filter((utxo) => {
    return (
        utxo.interfaceViewValue.amount === '10000.0000000000' &&
        utxo.interfaceViewValue.instrumentId.id === 'Amulet'
    )
})

if (senderAmuletUtxos.length === 0) {
    throw new Error('No UTXOs found for Sender')
}

logger.info('Tap command for Amulet for Sender submitted and UTXO received')

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 <https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0056/cip-0056.md> 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 { SDK, 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 = await SDK.create({
        auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
        ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
        token: global.TOKEN_NAMESPACE_CONFIG,
    })

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

    const utxos = await sdk.token.utxos.list({ partyId: sender })

    const utxosToUse = utxos.filter((t) => t.interfaceViewValue.amount != '50') //we filter out the 50, since we want to send 125

    await sdk.token.transfer.create({
        sender,
        recipient: receiver,
        amount: '2000',
        instrumentId: 'Amulet',
        registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
        inputUtxos: utxosToUse.map((t) => t.contractId),
    })
}

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 { SDK, localNetStaticConfig } from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = await SDK.create({
        auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
        ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
        token: global.TOKEN_NAMESPACE_CONFIG,
    })

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

    const [transferCommand, disclosedContracts] =
        await sdk.token.transfer.create({
            sender,
            recipient: receiver,
            amount: '2000',
            instrumentId: 'Amulet',
            registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
        })

    sdk.ledger.prepare({
        partyId: sender,
        commands: transferCommand,
        disclosedContracts,
    })
}

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 { SDK, 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 = await SDK.create({
        auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
        ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
    })

    const transaction = global.PREPARED_TRANSACTION
    if (!transaction.preparedTransaction) {
        throw Error('Prepared tx not found')
    }

    const calculatedTxHash = await sdk.utils.hash.preparedTransacation(
        transaction.preparedTransaction
    )
    const hex = calculatedTxHash.toHex()
    const base64 = calculatedTxHash.toBase64()

    if (base64 !== transaction.preparedTransactionHash)
        throw Error('Incorrect hash calculated')
}

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 {
    SDK,
    localNetStaticConfig,
    signTransactionHash,
} from '@canton-network/wallet-sdk'

export default async function () {
    const sdk = await SDK.create({
        auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
        ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
    })
    const keys = sdk.keys.generate()

    const preparedParty = EXISTING_TOPOLOGY

    //This signing function works for a party topology hash or a transaction hash
    signTransactionHash(preparedParty.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 {
    SDK,
    localNetStaticConfig,
    signTransactionHash,
} from '@canton-network/wallet-sdk'
import { v4 } from 'uuid'

export default async function () {
    const sdk = await SDK.create({
        auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
        ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
    })
    const myParty = global.EXISTING_PARTY_1
    const keys = global.EXISTING_PARTY_1_KEYS

    const pingCommand = [
        {
            CreateCommand: {
                templateId:
                    '#canton-builtin-admin-workflow-ping:Canton.Internal.Ping:Ping',
                createArguments: {
                    id: v4(),
                    initiator: myParty,
                    responder: myParty,
                },
            },
        },
    ]

    const preparedPingCommand = sdk.ledger.prepare({
        partyId: myParty,
        commands: pingCommand,
        disclosedContracts: [],
    })

    const { response: preparedPingCommandResponse } =
        await preparedPingCommand.toJSON()

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

    const signed = sdk.ledger.fromSignature(
        preparedPingCommandResponse,
        signature
    )
    await sdk.ledger.execute(signed, { partyId: myParty })
}

Observing the Transaction

The execute method in the ledger` namespace will execute the submission and wait for a response. THs returns an updateId and completionOffset. Additionally, you can continuously monitor holdings changes using token standard history parser.

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 {
    localNetStaticConfig,
    SDK,
    signTransactionHash,
} from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import {
    TOKEN_NAMESPACE_CONFIG,
    TOKEN_PROVIDER_CONFIG_DEFAULT,
    AMULET_NAMESPACE_CONFIG,
} from './utils/index.js'

const onlineLogger = pino({ name: '14-online-localnet', level: 'info' })
const offlineLogger = pino({ name: '14-oggline-localnet', level: 'info' })

const onlineSDK = await SDK.create({
    auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
    ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
    amulet: AMULET_NAMESPACE_CONFIG,
    token: TOKEN_NAMESPACE_CONFIG,
})

onlineLogger.info(`Online sdk initialized.`)

const offlineSdk = SDK.createOffline()

offlineLogger.info(`Offline sdk initialized.`)

offlineLogger.info(
    '===================== OFFLINE CREATED KEYS SENDER ====================='
)

const keyPairSender = offlineSdk.keys.generate()

onlineLogger.info(
    '===================== ONLINE CREATED TOPOLOGY TRANSACTIONS SENDER ====================='
)

const senderPartyPrepared = onlineSDK.party.external.create(
    keyPairSender.publicKey,
    {
        partyHint: 'v1-14-alice',
    }
)

const senderPartyTopology = await senderPartyPrepared.topology()

onlineLogger.info(
    `Prepared sender onboarding with multi hash: ${senderPartyTopology.multiHash}`
)

offlineLogger.info(
    '===================== OFFLINE TOPOLOGY TX HASHING SENDER ====================='
)

const senderTopologyTxCalculated =
    await offlineSdk.utils.hash.topologyTransaction(
        senderPartyTopology.topologyTransactions
    )

if (senderTopologyTxCalculated !== senderPartyTopology.multiHash)
    throw Error(
        'Recomputed sender topology hash does not match received sender multihash'
    )

const senderSignedTopologyTx = signTransactionHash(
    senderPartyTopology.multiHash,
    keyPairSender.privateKey
)

offlineLogger.info(`Sender signed onboarding hash`)

onlineLogger.info(
    '===================== ONLINE EXECUTE TOPOLOGY TX SENDER ====================='
)

const senderParty = await senderPartyPrepared.execute(senderSignedTopologyTx)

onlineLogger.info(`Created sender party: ${senderParty}`)

offlineLogger.info(
    '===================== OFFLINE GENERATE KEYS RECEIVER ====================='
)

const keyPairReceiver = offlineSdk.keys.generate()

offlineLogger.info('Created sender keyPair')

onlineLogger.info(
    '===================== ONLINE CREATED TOPOLOGY TRANSACTIONS RECEIVER ====================='
)

const receiverPartyPrepared = onlineSDK.party.external.create(
    keyPairReceiver.publicKey,
    {
        partyHint: 'v1-14-bob',
    }
)

const receiverPartyTopology = await receiverPartyPrepared.topology()

onlineLogger.info(
    `Prepared sender onboarding with multi hash: ${receiverPartyTopology.multiHash}`
)

offlineLogger.info(
    '===================== OFFLINE COMPUTE MULTIHASH FROM TOPOLOGY TX RECEIVER ====================='
)

const receiverTopologyHashCalculated =
    await offlineSdk.utils.hash.topologyTransaction(
        receiverPartyTopology.topologyTransactions
    )

if (receiverTopologyHashCalculated !== receiverPartyTopology.multiHash)
    throw Error(
        'Recomputed receiver topology hash does not match received multihash'
    )

const receiverSignedTopologyTx = signTransactionHash(
    receiverPartyTopology.multiHash,
    keyPairReceiver.privateKey
)

offlineLogger.info(`Receiver signed onboarding hash`)

onlineLogger.info(
    '===================== ONLINE EXECUTE TOPOLOGY TX FOR RECEIVER ====================='
)

const receiverParty = await receiverPartyPrepared.execute(
    receiverSignedTopologyTx
)

onlineLogger.info(`Created receiver party: ${receiverParty}`)

// Configure amulet namespace for online sdk

onlineLogger.info(
    '===================== ONLINE SENDER TAP (PREPARE) ====================='
)

const [amuletTapCommand, amuletTapDisclosedContracts] =
    await onlineSDK.amulet.tap(senderParty.partyId, '10000')

const { response: preparedTapCommandResponse } = await onlineSDK.ledger
    .prepare({
        partyId: senderParty.partyId,
        commands: amuletTapCommand,
        disclosedContracts: amuletTapDisclosedContracts,
    })
    .toJSON()

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

offlineLogger.info(
    '===================== OFFLINE TAP SIGNING AND HASH RECOMPUTATION ====================='
)
const calculatedTxHash = await offlineSdk.utils.hash.preparedTransacation(
    preparedTapCommandResponse.preparedTransaction
)

if (
    calculatedTxHash.toBase64() !==
    preparedTapCommandResponse.preparedTransactionHash
)
    throw Error('Recomputed tap hash does not match prepared tap hash')

const signatureTapCommand = signTransactionHash(
    preparedTapCommandResponse.preparedTransactionHash,
    keyPairSender.privateKey
)
offlineLogger.info('Signed tap transaction hash')

const signed = onlineSDK.ledger.fromSignature(
    preparedTapCommandResponse,
    signatureTapCommand
)

onlineLogger.info(
    '===================== ONLINE EXECUTE TAP COMMAND SENDER ====================='
)

await onlineSDK.ledger.execute(signed, { partyId: senderParty.partyId })

onlineLogger.info('Tap completed')

//creating a transfer
onlineLogger.info(
    '===================== ONLINE SENDER TRANSFER (PREPARE) ====================='
)

const [transferCommand, transferDisclosedContracts] =
    await onlineSDK.token.transfer.create({
        sender: senderParty.partyId,
        recipient: receiverParty.partyId,
        instrumentId: 'Amulet',
        registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
        amount: '100',
    })

const { response: preparedTransferResponse } = await onlineSDK.ledger
    .prepare({
        partyId: senderParty.partyId,
        commands: transferCommand,
        disclosedContracts: transferDisclosedContracts,
    })
    .toJSON()

offlineLogger.info(
    '===================== OFFLINE TRANSFER SIGNING AND HASH RECOMPUTATION ====================='
)

onlineLogger.info(
    `Prepared create transfer with hash: ${preparedTransferResponse.preparedTransactionHash}`
)

const calculatedCreateTransferHash =
    await offlineSdk.utils.hash.preparedTransacation(
        preparedTransferResponse.preparedTransaction
    )

if (
    calculatedCreateTransferHash.toBase64() !==
    preparedTransferResponse.preparedTransactionHash
)
    throw Error(
        'Recomputed create transfer hash does not match prepared create transfer hash'
    )

const signatureTransferCommand = signTransactionHash(
    preparedTransferResponse.preparedTransactionHash,
    keyPairSender.privateKey
)
offlineLogger.info('Signed create transfer transaction hash')

const signedTransferHash = onlineSDK.ledger.fromSignature(
    preparedTransferResponse,
    signatureTransferCommand
)

onlineLogger.info(
    '====================== SUBMITTING TRANSFER ====================='
)

await onlineSDK.ledger.execute(signedTransferHash, {
    partyId: senderParty.partyId,
})

onlineLogger.info(
    `Created a transfer from ${senderParty.partyId} to ${receiverParty.partyId}`
)

onlineLogger.info(
    '===================== ONLINE ACCEPT TRANSFER (PREPARE) ====================='
)

const pendingOffers = await onlineSDK.token.transfer.pending(
    receiverParty.partyId
)

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, transferDisclosedContractsAccept] =
    await onlineSDK.token.transfer.accept({
        transferInstructionCid: pendingOffer.contractId,
        registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
    })

const { response: preparedTransferAcceptResponse } = await onlineSDK.ledger
    .prepare({
        partyId: receiverParty.partyId,
        commands: acceptTransferCommand,
        disclosedContracts: transferDisclosedContractsAccept,
    })
    .toJSON()

onlineLogger.info(
    `Prepared create transfer with hash: ${preparedTransferAcceptResponse.preparedTransactionHash}`
)

offlineLogger.info(
    '===================== OFFLINE CALCULATE TX HASH AND SIGNING FOR ACCEPT TRANSFER ====================='
)

const calculatedAcceptTransferHash =
    await offlineSdk.utils.hash.preparedTransacation(
        preparedTransferAcceptResponse.preparedTransaction
    )

if (
    calculatedAcceptTransferHash.toBase64() !==
    preparedTransferAcceptResponse.preparedTransactionHash
)
    throw Error(
        'Recomputed accept transfer hash does not match prepared accept transfer hash'
    )

const signatureAcceptTransfer = signTransactionHash(
    preparedTransferAcceptResponse.preparedTransactionHash,
    keyPairReceiver.privateKey
)
offlineLogger.info('Signed accept transfer transaction hash')

const signedAcceptTransferHash = onlineSDK.ledger.fromSignature(
    preparedTransferAcceptResponse,
    signatureAcceptTransfer
)

onlineLogger.info(
    '===================== ONLINE SUBMITTING ACCEPT ====================='
)

await onlineSDK.ledger.execute(signedAcceptTransferHash, {
    partyId: receiverParty.partyId,
})

onlineLogger.info('Accepted transfer instruction')