钱包集成指南
Integrate with the Canton Network as a 钱包: onboard external Party, read data, sign 交易, and support Canton Coin(CC) and USDCx.
Canton Network 概览
High-level 概览
Canton Network is a public layer 1 blockchain 网络 with privacy. It is designed for financial institutions and DeFi alike to facilitate secure, interoperable, and privacy-preserving 交易 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 应用 with real-time synchronization and guaranteed privacy.
Technology: The Canton Network is designed as a “网络 of networks,” where each participating institution maintains its own sub-ledger while connecting with others via a shared synchronization layer. 治理: The 全局同步器基金会(GSF) (GSF), an independent, non-profit body under the Linux Foundation, governs the global 同步器. 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 提供方 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. 验证者 nodes are responsible for storing 合约 data and executing smart 合约 code. The synchronizers, in turn, distribute encrypted messages and facilitate 交易 coordination. 交易 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 交易 get replicated to all nodes/validators.
- In Canton, state and 交易 get distributed only to nodes/validators that are specified in the smart 合约.
Party
In Canton, Party are the core on-ledger identities, and are the 钱包 addresses, similar to an address or Externally Owned 账户 (EOA) on other blockchains. They are central to how permissions and privacy are managed within the 网络.
Party Permissions and Roles
Smart 合约 specify permissions for different Party, dictating what they can and can’t do. Depending on their role, Party:
- Validate specific 交易, such as a transfer of assets.
- Control certain actions, like initiating transfers.
- See specific state and 交易, such as a record of their 持仓.
Privacy is maintained at the party level, meaning 交易 and state data is only shared with the Party who need to see it, ensuring a high degree of confidentiality.
Local Party vs External Party
Party come in two forms, internal and external. An internal party is created on the 验证者节点, it gives a 验证者节点 submission rights and therefore holds its key on the 验证者节点. 交易 are signed using the 验证者 own internal keys for signing (and thereby the validator operator has full control of everything that happens on the party).
External Party are similar to how node interactions happens on other networks and therefore Externally Owned 账户. In this case the signing key can be held externally and a signature is required alongside the 交易 to authorize the action. For external Party the base flow follows three steps: 准备 a 交易, sign the 交易 and submit the 交易. 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 Party, see the Local and external Party documentation section here.
Onboarding and Format
Party 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).
若你 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 交易 that authorizes a node to host it. The designated node must then submit a matching 交易 to officially accept the hosting 请求. Instructions on how to do that can be found here.
Party Hosting
Since not every 用户 wants to host a node, Party are associated with 验证者节点. These validators “host” Party 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 参与者.
To participate in the 网络, a party must designate one or more 验证者节点 to host their data. This relationship, known as Party Hosting, is established through a topology 交易.
Advice on using Party for 钱包 提供方
Unlike Ethereum or Bitcoin addresses, creating Party has a cost associated to them and they create state on the 验证者节点 which means that they’re not as ephemeral as on other chains. Therefore, it’s suggested to avoid using Party for use cases such as “deposit addresses”.
- For wallets, it’s suggested to aim for one Party per key pair to represent the 钱包.
- For custodians, it’s suggested aiming for one Party per 账户/钱包.
- For exchanges, it’s suggested aiming for one, or few Party for the exchange vault and using memo tags for tracking deposits. See here for more information.
Consequences & Implications
Reading Data and 验证者 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 用户’s data, you must specifically connect to the validator that hosts that party. There is no single, all-encompassing blockchain RPC 端点 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 提供方’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 合约 level, as data is only distributed to the Party who have a legitimate need to see it.
- Light Node Footprint: Nodes only process their own 交易, not the entire 网络’s, which keeps them lightweight and efficient.
- Scalability: The 网络 can be scaled by simply adding more nodes.
However, this architecture has the consequence of decentralized data access, as previously mentioned.
To offer 服务 on the Canton Network, you will need a 验证者节点 to host your Party and your customers’ Party. You have two options for this: you can self-host a node or use a node-as-a-服务 提供方.
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 应用 and 集成 can be tested with new 网络 upgrades. 若你 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 验证者节点 operator community on Slack.
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/integrating-with-canton-网络/index.rst” hash=“241a9637” */}
Integrating with the Canton Network
When integrating with the Canton Network, we recommend that 钱包 提供方 support the necessary features outlined below to optimal 用户 experience. Additionally, there are optional features that can further enhance the 集成 and provide additional value to 用户.
Necessary Features
以下 features are required for 钱包 提供方 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 钱包 SDK is in the Token Standard section of this guide.
- Provide support specifically for Canton Coin(CC) and USDCx. The Canton Coin(CC) package of Amulet is preinstalled with all validators and USDCx is issued with the Digital Asset Registry and that dars for that 应用 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
可选 Features
While optional for 钱包 提供方, the following features are strongly recommended to ensure full support for the Canton Network and maximize 用户 adoption:
- Canton Coin(CC) pre-approvals. Documentation on how to implement pre-approvals with the 钱包 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 钱包 and dApp 集成.
- 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 钱包 提供方 to support such as supporting xReserves deposits and withdrawals and integrating the xReserve UI into the 钱包 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.
How to install the 钱包 SDK
The 钱包 SDK is available as a package on the NPM registry. 你可以 install it using your preferred package manager.
npm install @canton-网络/钱包-sdk .. group-tab:: yarn .. code:: shell
yarn add @canton-网络/钱包-sdk .. group-tab:: pnpm .. code:: shell
pnpm add @canton-网络/钱包-sdk
Alternatively, to do dApp 开发 only, the dApp SDK can be used which has a smaller bundle size and is optimized for browser usage. The dApp SDK can be installed with:
npm install @canton-网络/dapp-sdk .. group-tab:: yarn .. code:: shell
yarn add @canton-网络/dapp-sdk .. group-tab:: pnpm .. code:: shell
pnpm add @canton-网络/dapp-sdk
Both SDKs use the same underlying core packages and where only partial code is needed (like for 交易 visualization or hash verification) those packages can be used independently.
Hosting a 验证者
As stated in the Implications for 钱包 提供方 section here, it’s important for 钱包 提供方 to have a validator to host their 用户’ Party. It’s also strongly advised to operate a node in all three 网络 environments so that you can test and verify your 应用 and 集成 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.:
The 钱包 集成 guide is tailored to work with a LocalNet setup (/sdks-tools/开发-tools/localnet) to make 测试 and verification easy.
Connecting to a 同步器
For onboarding a validator with the global 同步器 it is recommended to read the Splice documentation here: /global-同步器/deployment/onboarding-process
Supporting Tokens and 应用
To integrate and support tokens, it is recommended to use the Splice documentation here: /global-同步器/deployment/onboarding-process
若你 are interested in building your own 应用, a good first place would be to utilize the CN quickstart: https://github.com/digital-asset/cn-quickstart
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/party-management/index.rst” hash=“4a434174” */}
创建 an External Party (钱包)
概览
Party represent acting entities in the 网络 and all 交易 happens between one or more Party. To understand more about Party see the Party in the 概览.
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 钱包 SDK.
How do I quickly allocate a party?
Using the 钱包 SDK you can quickly allocate a party using the following code snippet:
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import {
SDK,
TokenProviderConfig,
localNetStaticConfig,
} from '@canton-network/wallet-sdk'
export default async function () {
const auth: TokenProviderConfig = {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: 'ledger-api-user',
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
}
/*
if using OAuth, provide a different auth config when initializing the SDK such as:
const auth = {
method: 'client_credentials',
configUrl: 'https://my-oauth-url',
credentials: {
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
audience: `https://daml.com/jwt/aud/participant/${participantId}`,
scope: 'openid daml_ledger_api offline_access',
},
}
*/
const sdk = await SDK.create({
auth,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
})
const key = sdk.keys.generate()
// partyHint is optional but recommended to make it easier to identify the party
const partyHint = 'my-wallet-1'
await sdk.party.external
.create(key.publicKey, { partyHint })
.sign(key.privateKey)
.execute()
}
```
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
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')
```
创建 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 { 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,
})
sdk.keys.generate()
}
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 备份 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/wallet-sdk'
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)
}
}
Choosing a party hint
The unique party id is defined as ${partyHint}::${fingerprint}. The partyHint is a 用户 friendly name and can be anything that is unique for the fingerprint, e.g. “alice”, “bob” or “my-钱包-1”. It is recommended to include a hint when setting up the party (see quick-party-allocation for an example).
Generate the fingerprint
The 钱包 SDK has a built in function to generate the fingerprint:
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,
})
const keys = EXISTING_PARTY_1_KEYS
await sdk.keys.fingerprint(keys.publicKey)
}
this can be used to determine the unique party id beforehand or recompute the fingerprint based on the public key.
Generating the topology 交易
When onboarding using external signing, multiple topology 交易 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 交易 that needs to be generated are:
- `PartyToParticipant`: This 交易 indicates that the party agrees to be hosted by the 参与者 (validator).
- `ParticipantToParty`: This 交易 indicates that the 参与者 (validator) agrees to host the party.
- `KeyToParty`: This 交易 indicates that the key (public key) is associated with the party.
Once all the 交易 are built they can be combined into a single hash and submitted as part of a single signature. The 钱包 SDK has helper functions to generate these 交易:
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 key = sdk.keys.generate()
// partyHint is optional but recommended to make it easier to identify the party
const partyHint = 'my-wallet-1'
const prepared = sdk.party.external.create(key.publicKey, {
partyHint,
})
await prepared.topology()
}
Decoding the topology 交易
Sometimes converting the topology 交易 to human readable json might be needed, for this you can use the .decode() function:
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,
amulet: AMULET_NAMESPACE_CONFIG,
})
const sender = global.EXISTING_PARTY_1
const [tapCommand, disclosedContracts] = await sdk.amulet.tap(
sender,
'2000'
)
const preparedTransaction = sdk.ledger.prepare({
commands: tapCommand,
disclosedContracts,
partyId: sender,
})
await preparedTransaction.decode()
}
签名 multi-hash
Since the topology 交易 need to be submitted together the combined hash needs to be signed. The 钱包 SDK has a helper function to sign the combined hash:
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)
}
提交 the topology 交易
Once the signature is generated, the topology 交易 can be submitted to the validator. The 钱包 SDK has a helper function to submit the 交易:
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,
})
//Online signing
const keys = sdk.keys.generate()
await sdk.party.external
.create(keys.publicKey, {
partyHint: 'snippet-party-hint',
})
.sign(keys.privateKey)
.execute()
//offline signing where the keys are held externally
const offlineSigningKeys = sdk.keys.generate()
const receiverPartyCreation = sdk.party.external.create(
offlineSigningKeys.publicKey,
{
partyHint: 'offline-signing-party',
}
)
const unsignedReceiver = await receiverPartyCreation.topology()
// offline signing simulation - in most cases a signing provider would sign the multihash
const receiverPartySignature = signTransactionHash(
unsignedReceiver.multiHash,
offlineSigningKeys.privateKey
)
await receiverPartyCreation.execute(receiverPartySignature)
}
Multi-hosting a party
Since only relevant data is shared between 验证者节点, and nodes don’t contain all data, 备份 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 交易 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-用户 and app-提供方 validators.
import pino from 'pino'
import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk'
import {
TOKEN_PROVIDER_CONFIG_DEFAULT,
AMULET_NAMESPACE_CONFIG,
} from './utils/index.js'
const logger = pino({ name: 'v1-03-parties', level: 'info' })
const userId = localNetStaticConfig.LOCALNET_USER_ID
const sdk = await SDK.create({
auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
amulet: AMULET_NAMESPACE_CONFIG,
})
const allocatedParties = await Promise.all(
['v1-03-alice', 'v1-03-bob'].map((partyHint) => {
const partyKeys = sdk.keys.generate()
return sdk.party.external
.create(partyKeys.publicKey, {
partyHint,
})
.sign(partyKeys.privateKey)
.execute()
})
)
logger.info(allocatedParties, 'Allocated parties')
const listedParties = await sdk.party.list()
logger.info(listedParties, `Obtained parties for ${userId}`)
const allocatedPartiesIds = new Set(
allocatedParties.map((party) => party.partyId)
)
if (!allocatedPartiesIds.isSubsetOf(new Set(listedParties))) {
throw new Error(
"At least some of the allocated parties haven't been listed."
)
}
const featuredAppRights = await sdk.amulet.featuredApp.grant()
if (!featuredAppRights) {
throw new Error(
'Failed to obtain featured app rights for validator operator party'
)
} else {
logger.info(
featuredAppRights,
'Featured app rights for validator operator party'
)
}
logger.info('Preparing multi hosted party...')
const participantEndpoints = [
{
url: new URL('http://127.0.0.1:3975'),
tokenProviderConfig: TOKEN_PROVIDER_CONFIG_DEFAULT,
},
]
const charlieKeys = sdk.keys.generate()
const charlie = await sdk.party.external
.create(charlieKeys.publicKey, {
partyHint: 'v1-03-charlie',
confirmingParticipantEndpoints: participantEndpoints,
})
.sign(charlieKeys.privateKey)
.execute()
logger.info(charlie, 'Multi hosted party allocated successfully')
const charliePingCommand = sdk.utils.ping.create([
{ initiator: charlie.partyId, responder: charlie.partyId },
])
const pingResult = await sdk.ledger
.prepare({
partyId: charlie.partyId,
commands: charliePingCommand,
})
.sign(charlieKeys.privateKey)
.execute({
partyId: charlie.partyId,
})
logger.info(
pingResult,
'Successfully validated party allocation via Canton.Internal.Ping'
)
logger.info('Preparing multi hosted party with observing participant...')
const observingCharlieKeys = sdk.keys.generate()
const observingCharlie = await sdk.party.external
.create(observingCharlieKeys.publicKey, {
partyHint: 'v1-03-observingCharlie',
observingParticipantEndpoints: participantEndpoints,
})
.sign(observingCharlieKeys.privateKey)
.execute()
logger.info(
observingCharlie,
'Multi hosted party with observing participant allocated successfully'
)
const observingConradPingCommand = sdk.utils.ping.create([
{
initiator: observingCharlie.partyId,
responder: observingCharlie.partyId,
},
])
const observingPingResult = await sdk.ledger
.prepare({
partyId: observingCharlie.partyId,
commands: observingConradPingCommand,
})
.sign(observingCharlieKeys.privateKey)
.execute({
partyId: observingCharlie.partyId,
})
logger.info(
observingPingResult,
'Successfully validated observing party allocation via Canton.Internal.Ping'
)
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/finding-and-reading-data/index.rst” hash=“20fe15cb” */}
Finding and Reading Data
The 钱包 SDK primarily focus on an on-party basis interaction, therefore it is almost always required to define the party you are using fo each 命令/
Reading Available Party
Reading all available Party to you can easily be done using the 钱包 SDK as shown in the example below, and the result is paginated. It’s worth noting that the call to read all available Party doesn’t use the the party and 同步器 fields therefore changing them has no effect on the result.
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,
})
await sdk.party.list()
}
Reading Ledger End
A lot of different requests will take a ledger 偏移 to ensure the requested time correlates with ledger time. A 验证者 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 交易 prior to commit.
- 记录时间 - this is the time assigned by the sequencer when registering the confirmation 请求.
Ledger time should be used for all operations in your local environment (that does not affect partners). When doing reconciliation for 交易 with partners or other members of a 同步器 it is better to use 记录时间.
Ledger end is used as a default for 钱包 SDK operations.
Reading Active 合约
Using the above ledger time we can figure out what the current state of all active 合约 are. 合约 can be in two states - active and archived - which correlates to the UTXO mode of unspent and spent. Active 合约 are 合约 that are unspent and thereby can be used in new 交易 or to exercise choices.
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,
})
const myParty = global.EXISTING_PARTY_1
//we use holdings as an example here
const myTemplateId = '#splice-amulet:Splice.Amulet:Amulet'
await sdk.ledger.acsReader.read({
parties: [myParty],
templateIds: [myTemplateId], //this is optional for if you want to filter by template id
filterByParty: true,
})
}
Visualizing a 交易
The 钱包 SDK uses a 交易 parsing transform a fully fledged 交易 tree into human recognizable 交易 view. The full code for the 交易 parsing can be found at parser typescript class.
The 钱包 SDK uses this parser to transform all 交易 tree interacted with into PrettyTransactions.
for instance on the getTransactionById or listHoldingTransactions (Detailed here).
The 交易 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 交易 can contain multiple 事件 (deposits and withdrawals are considered 事件). In order to figure out the on chain 交易 it is required to iterate over all the 事件. The 事件 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 事件 types and how to potentially visualize the 交易 for a client
Here is an example on how a "tap" 事件 looks like (Performing tap):
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
"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:
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"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" 事件 we don't have any locked holding changes, however we do have an unlocked create 事件:
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"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
}
]
}
```
A Merge or split is usually done by performing a transfer to yourself, by selecting several input utxos they can be consolidated into one and likewise a transfer to yourself of one big utxo can be used to split it into two. Below is the usual merge split that you would see if you use an utxo that is bigger than the transferred amount when performing a 2-step transfer:
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
"updateId": "1220f5c5a8403d830babd0b25124701ec59c0540d3f75377fe48df34c89a955f7bfc",
"offset": 1316,
"recordTime": "2025-10-14T02:11:47.509312Z",
"synchronizerId": "global-domain::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"events": [
{
"label": {
"burnAmount": "0",
"mintAmount": "0",
"type": "MergeSplit",
"tokenStandardChoice": {
"name": "TransferFactory_Transfer",
"choiceArgument": {
"expectedAdmin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"transfer": {
"sender": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"receiver": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"requestedAt": "2025-10-14T02:10:47.406Z",
"executeBefore": "2025-10-15T02:11:47.406Z",
"inputHoldingCids": [
"00cee8d2659d5966962fbda321aae358092eafbb162d46f2639a8da0688ef3ee8aca11122096aeb27e0fa9c03a3209fda9db88e4e67a2ba1509f094bdd82a38d844ca65305"
],
"meta": {
"values": {
"splice.lfdecentralizedtrust.org/reason": "memo-ref"
}
}
},
"extraArgs": {
"context": {
"values": {
"amulet-rules": {
"tag": "AV_ContractId",
"value": "00ccd166b3b91e776fc1d2270d3ffdff551bd43117c679a145281d4c4545fa8bc7ca1112204f1604f06e4c616656c39678e173f68a2ae3a96af984b71120a6da57ca34d66c"
},
"open-round": {
"tag": "AV_ContractId",
"value": "003a5299317dd50dc84c2a645f2b233f69dfd16286ddf9dd2996c764ff93e591cbca11122008b6e9f88a00262449d8e6bf4de2edf50b85a160c068744b53091653fb107b9f"
}
}
},
"meta": {
"values": {}
}
}
},
"exerciseResult": {
"output": {
"tag": "TransferInstructionResult_Pending",
"value": {
"transferInstructionCid": "002e296168aeabe02e40c04874c794a5855f918b8acf950f56b2b941c09305fd4bca1112201bda3ae677b8a26e682f6869c97afeea9136852ee31e638045f6be87d6c0e953"
}
},
"senderChangeCids": [
"00ba31e046f04908e6bf6fe5eeb725f0e2054f37353e85c7ef99a0924df8c1b891ca11122046dee24aeae91d4a5fc0570458cad8ccc94002645b6a6e2c82cc5f07ca897fe9"
],
"meta": {
"values": {}
}
}
},
"reason": "memo-ref",
"meta": {
"values": {}
}
},
"lockedHoldingsChange": {
"creates": [
{
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "00b0af2ac89701b35d60b52fa239a66a993b383f963a9230f3f04e6510290dbe95ca1112200df1866e4ed7b50d6f9ac02b348a47a47b70c4d74f8d9575a1453ee9fe175f1b",
"owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": {
"holders": [
"DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36"
],
"expiresAt": "2025-10-15T02:11:47.406Z",
"expiresAfter": null,
"context": "transfer to 'bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783'"
}
}
]
},
"lockedHoldingsChangeSummary": {
"numOutputs": 1,
"outputAmount": "100",
"amountChange": "100"
},
"unlockedHoldingsChange": {
"creates": [
{
"amount": "1999900.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "00ba31e046f04908e6bf6fe5eeb725f0e2054f37353e85c7ef99a0924df8c1b891ca11122046dee24aeae91d4a5fc0570458cad8ccc94002645b6a6e2c82cc5f07ca897fe9",
"owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": null
}
],
"archives": [
{
"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": {
"numInputs": 1,
"inputAmount": "2000000",
"numOutputs": 1,
"outputAmount": "1999900",
"amountChange": "-100"
},
"transferInstruction": {
"originalInstructionCid": null,
"transfer": {
"sender": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"receiver": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"requestedAt": "2025-10-14T02:10:47.406Z",
"executeBefore": "2025-10-15T02:11:47.406Z",
"inputHoldingCids": [
"00cee8d2659d5966962fbda321aae358092eafbb162d46f2639a8da0688ef3ee8aca11122096aeb27e0fa9c03a3209fda9db88e4e67a2ba1509f094bdd82a38d844ca65305"
],
"meta": {
"values": {
"splice.lfdecentralizedtrust.org/reason": "memo-ref"
}
}
},
"status": {
"before": null
},
"meta": null
}
}
]
}
```
The label gives us the quick information
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"label": {
"burnAmount": "0", // how much was burned
"mintAmount": "0", // how much was minted
"type": "MergeSplit", // event type
"tokenStandardChoice": { // the entire token standard choice
},
"reason": "memo-ref", // memo tag
"meta": { // any other relevant meta data
"values": {}
}
}
```
The locked holding change 展示 one new utxo equivalent to the amount send to Bob. Once Bob accepts the transfer this locked utxo would be archived.information.
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"lockedHoldingsChange": {
// we have 1 new locked holding of the transfer amount (100)
"creates": [
{
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "00b0af2ac89701b35d60b52fa239a66a993b383f963a9230f3f04e6510290dbe95ca1112200df1866e4ed7b50d6f9ac02b348a47a47b70c4d74f8d9575a1453ee9fe175f1b",
// alice is still the owner since this a locked utxo, until bob accepts
"owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": {
// the DSO (instrument Admin) is holder of the lock
"holders": [
"DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36"
],
"expiresAt": "2025-10-15T02:11:47.406Z",
"expiresAfter": null,
"context": "transfer to 'bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783'"
}
}
]
},
//overview of how holdings have changed
"lockedHoldingsChangeSummary": {
"numOutputs": 1,
"outputAmount": "100",
"amountChange": "100"
},
```
There is also an unlocked holding change, consist of one create and one archive. Since alice had one 交易 of 2000000.0000000000, and only send 100, then she gets the remaining 1999900.0000000000 back:
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"unlockedHoldingsChange": {
// creates the new utxo for alice with the unspent amount
"creates": [
{
"amount": "1999900.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "00ba31e046f04908e6bf6fe5eeb725f0e2054f37353e85c7ef99a0924df8c1b891ca11122046dee24aeae91d4a5fc0570458cad8ccc94002645b6a6e2c82cc5f07ca897fe9",
"owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": null
}
],
// archives the old spend utxo
"archives": [
{
"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
}
]
},
// overview of how this has changed alices utxos
"unlockedHoldingsChangeSummary": {
"numInputs": 1,
"inputAmount": "2000000",
"numOutputs": 1,
"outputAmount": "1999900",
"amountChange": "-100"
}
```
When Bob accepts the transfer we see the actual transfer out 事件. This is seen from Alice point of view
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
"updateId": "122064654ed225797ea8161eb0730d38beff917201cecb00079666192cda648bb182",
"offset": 1319,
"recordTime": "2025-10-14T02:11:48.989233Z",
"synchronizerId": "global-domain::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"events": [
{
"label": {
"burnAmount": "0",
"mintAmount": "0",
"type": "TransferOut",
"receiverAmounts": [
{
"receiver": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"amount": "100"
}
],
"tokenStandardChoice": {
"name": "TransferInstruction_Accept",
"choiceArgument": {
"extraArgs": {
"context": {
"values": {
"amulet-rules": {
"tag": "AV_ContractId",
"value": "00ccd166b3b91e776fc1d2270d3ffdff551bd43117c679a145281d4c4545fa8bc7ca1112204f1604f06e4c616656c39678e173f68a2ae3a96af984b71120a6da57ca34d66c"
},
"expire-lock": {
"tag": "AV_Bool",
"value": true
},
"open-round": {
"tag": "AV_ContractId",
"value": "003a5299317dd50dc84c2a645f2b233f69dfd16286ddf9dd2996c764ff93e591cbca11122008b6e9f88a00262449d8e6bf4de2edf50b85a160c068744b53091653fb107b9f"
}
}
},
"meta": {
"values": {}
}
}
},
"exerciseResult": {
"output": {
"tag": "TransferInstructionResult_Completed",
"value": {
"receiverHoldingCids": [
"002e360f78cf28a40c742839572bbf7a683ba59c3db906757b284a2edf433700edca1112209685da5d5a5c531b51504601c175cbbeaba8956e7a7fc2926fa8909d8b81a95a"
]
}
},
"senderChangeCids": [],
"meta": {
"values": {}
}
}
},
"reason": "memo-ref",
"meta": {
"values": {}
}
},
"lockedHoldingsChange": {
"archives": [
{
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "00b0af2ac89701b35d60b52fa239a66a993b383f963a9230f3f04e6510290dbe95ca1112200df1866e4ed7b50d6f9ac02b348a47a47b70c4d74f8d9575a1453ee9fe175f1b",
"owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": {
"holders": [
"DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36"
],
"expiresAt": "2025-10-15T02:11:47.406Z",
"expiresAfter": null,
"context": "transfer to 'bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783'"
}
}
]
},
"lockedHoldingsChangeSummary": {
"numInputs": 1,
"inputAmount": "100",
"amountChange": "-100"
},
"transferInstruction": {
"originalInstructionCid": null,
"transfer": {
"sender": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"receiver": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"requestedAt": "2025-10-14T02:10:47.406Z",
"executeBefore": "2025-10-15T02:11:47.406Z",
"inputHoldingCids": [
"00b0af2ac89701b35d60b52fa239a66a993b383f963a9230f3f04e6510290dbe95ca1112200df1866e4ed7b50d6f9ac02b348a47a47b70c4d74f8d9575a1453ee9fe175f1b"
],
"meta": {
"values": {
"splice.lfdecentralizedtrust.org/reason": "memo-ref"
}
}
},
"meta": {
"values": {}
},
"status": {
"before": {
"tag": "TransferPendingReceiverAcceptance",
"value": {}
}
}
}
}
]
}
```
The label gives us the quick information.
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"label": {
"burnAmount": "0", // how much was burned
"mintAmount": "0", // how much was minted
"type": "TransferOut", // event type
"receiverAmounts": [ // the list of receivers and how much
{
"receiver": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"amount": "100"
}
],
"tokenStandardChoice": { // the entire token standard choice
},
"reason": "memo-ref", // memo tag
"meta": { // any other meta data
"values": {}
}
}
```
We can see that the locked 100 transfer is now archived, on Bobs side he will see a 转账 In that creates an unlocked holding of 100.
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"lockedHoldingsChange": {
// The locked utxo is archived
"archives": [
{
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "00b0af2ac89701b35d60b52fa239a66a993b383f963a9230f3f04e6510290dbe95ca1112200df1866e4ed7b50d6f9ac02b348a47a47b70c4d74f8d9575a1453ee9fe175f1b",
"owner": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": {
"holders": [
"DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36"
],
"expiresAt": "2025-10-15T02:11:47.406Z",
"expiresAfter": null,
"context": "transfer to 'bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783'"
}
}
]
}
```
Bob will see a transfer in once he has accepted the transferInstruction from Alice. If Bob had set up transfer pre-approval, then he would only see the below transfer:
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
"updateId": "122064654ed225797ea8161eb0730d38beff917201cecb00079666192cda648bb182",
"offset": 1319,
"recordTime": "2025-10-14T02:11:48.989233Z",
"synchronizerId": "global-domain::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"events": [
{
"label": {
"type": "TransferIn",
"burnAmount": "0",
"mintAmount": "0",
"sender": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"tokenStandardChoice": {
"name": "TransferInstruction_Accept",
"choiceArgument": {
"extraArgs": {
"context": {
"values": {
"amulet-rules": {
"tag": "AV_ContractId",
"value": "00ccd166b3b91e776fc1d2270d3ffdff551bd43117c679a145281d4c4545fa8bc7ca1112204f1604f06e4c616656c39678e173f68a2ae3a96af984b71120a6da57ca34d66c"
},
"expire-lock": {
"tag": "AV_Bool",
"value": true
},
"open-round": {
"tag": "AV_ContractId",
"value": "003a5299317dd50dc84c2a645f2b233f69dfd16286ddf9dd2996c764ff93e591cbca11122008b6e9f88a00262449d8e6bf4de2edf50b85a160c068744b53091653fb107b9f"
}
}
},
"meta": {
"values": {}
}
}
},
"exerciseResult": {
"output": {
"tag": "TransferInstructionResult_Completed",
"value": {
"receiverHoldingCids": [
"002e360f78cf28a40c742839572bbf7a683ba59c3db906757b284a2edf433700edca1112209685da5d5a5c531b51504601c175cbbeaba8956e7a7fc2926fa8909d8b81a95a"
]
}
},
"senderChangeCids": [],
"meta": {
"values": {}
}
}
},
"reason": "memo-ref",
"meta": {
"values": {}
}
},
"unlockedHoldingsChange": {
"creates": [
{
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "002e360f78cf28a40c742839572bbf7a683ba59c3db906757b284a2edf433700edca1112209685da5d5a5c531b51504601c175cbbeaba8956e7a7fc2926fa8909d8b81a95a",
"owner": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"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": "100",
"amountChange": "100"
},
"transferInstruction": {
"originalInstructionCid": null,
"transfer": {
"sender": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"receiver": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"requestedAt": "2025-10-14T02:10:47.406Z",
"executeBefore": "2025-10-15T02:11:47.406Z",
"inputHoldingCids": [
"00b0af2ac89701b35d60b52fa239a66a993b383f963a9230f3f04e6510290dbe95ca1112200df1866e4ed7b50d6f9ac02b348a47a47b70c4d74f8d9575a1453ee9fe175f1b"
],
"meta": {
"values": {
"splice.lfdecentralizedtrust.org/reason": "memo-ref"
}
}
},
"meta": {
"values": {}
},
"status": {
"before": {
"tag": "TransferPendingReceiverAcceptance",
"value": {}
}
}
}
}
]
}
```
The label gives us the quick information.
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"label": {
"type": "TransferIn", // event type
"burnAmount": "0", // how much was burned
"mintAmount": "0", // how much was minted
// the original sender of the amount
"sender": "alice::12201acb807c49aceaeb68b1d89bb3bea95fe740b4b0a6cca428e6a351c2450540f4",
"tokenStandardChoice": {
},
"reason": "memo-ref", // memo tag
"meta": { // any other meta fields
"values": {}
}
}
```
Bob then also sees one new unlocked utxo for the 100.
```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
"unlockedHoldingsChange": {
// the new money available for Bob
"creates": [
{
"amount": "100.0000000000",
"instrumentId": {
"admin": "DSO::1220294d264ccf205000d72d9f0106e3a0e8ce8d34982d7f134c42d42d18750ccd36",
"id": "Amulet"
},
"contractId": "002e360f78cf28a40c742839572bbf7a683ba59c3db906757b284a2edf433700edca1112209685da5d5a5c531b51504601c175cbbeaba8956e7a7fc2926fa8909d8b81a95a",
"owner": "bob::1220447e99360f4e11caf7be818b96ead2a23c593eb927f792ae5f0a0bc15b264783",
"meta": {
"values": {
"amulet.splice.lfdecentralizedtrust.org/created-in-round": "32",
"amulet.splice.lfdecentralizedtrust.org/rate-per-round": "0.00380518"
}
},
"lock": null
}
]
}
```
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/preparing-and-signing-交易/index.rst” hash=“39205f15” */}
Preparing and Signing 交易 Using External Party
High-level Signing Process
The basic steps of preparing and signing a 交易 using an external party are as follows:
- Creating a 命令 - You start by simply creating a 命令.
- Preparing the 交易 - You send the 命令 to the blockchain RPC, offered by your node, to prepare the 交易.
- Validating the 交易 - You inspect the 交易 and decide whether to sign it.
- Signing the 交易 - Once validated, you sign the 交易 hash using your private key (typically with ECDSA/EdDSA).
- Submitting the 交易 - You submit the signed 交易 to be executed.
- Observing the 交易 - You observe the blockchain until the 交易 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(CC) following a token standard transfer.
How do I quickly execute a Ping?
Below 展示 how to quickly execute a ping 命令 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 命令
命令 are the intents of an 用户 on the validator, there are two kinds of 命令: 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 合约 being created. The ExerciseCommand takes an existing 合约 and exercises a choice on it, which also can result in new 合约 being created.
In the Canton Network, it is often necessary to need to include input data when creating 命令 which needs to be read from the ledger. 例如, 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. 例如, information about a particular asset which you don’t get from your own node - you get from an API provided by the asset issuer. 参见 here for more information.
The general process for forming a 交易 is:
- Call your own node’s RPC to get the current ledger end (think “latest block”)
- Call your own node’s RPC to get relevant private data at ledger end (e.g. 钱包’s 持仓)
- Call app/token specific APIs to get context information (e.g. mining round 合约)
- Assemble the data into the full 命令 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 命令. The transfer 命令 is sent to a recipient party who can then exercise accept or reject on the created 合约 (thereby archiving it). In the cURL example, we show the steps above gaining information from a validator and context information from the Canton Coin(CC) scan API.
The 钱包 SDK allow us to build such a 命令 easily:
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
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),
})
}
```
```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# In the examples below, replace the following with your own values: "YOUR_NODE_JSON_API", "OFFSET_FROM_1", "WALLET_ID", "YOUR_CHOICE_OF_INPUT_CIDs",
# "SENDER_PARTY_ID", "RECEIVER_PARTY_ID"
# 1. Call your own node’s RPC to get the latest offset / ledger end
curl -X GET http://YOUR_NODE_JSON_API/v2/state/ledger-end
# 2. Get the contract ID of an active Amulet contract via
curl -X POST http://YOUR_NODE_JSON_API/v2/state/active-contracts -d
'{ "verbose" : true,
"activeAtOffset": OFFSET_FROM_1,
"filter" : {
"filtersByParty" : {
"WALLET_ID" : {
"cumulative":
[{"identifierFilter": {
"InterfaceFilter": {
"value": {
"includeInterfaceView":true,
"includeCreatedEventBlob": false,
"interfaceId": "#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding"}}}}]}}}}'
# 3. Get context information for Canton Coin
#3. a) the Registry admin party id:
curl -X GET https://scan.sv-1.global.canton.network.sync.global/registry/metadata/v1/info
# Example output:
# {"adminId":"DSO::1220b143…","supportedApis":{"splice-api-token-metadata-v1":1}}
# 3. b) the instrument ID:
curl -X GET https://scan.sv-1.global.canton.network.sync.global/registry/metadata/v1/instruments
# Example output:
# "instrumentId" : {
# "admin" : "DSO::1220b1431ef217342db44d516bb9befde802be7d8899637d290895fa58880f19accc",
# "id" : "Amulet"}
# 3. c) Get the TransferFactory and context from the asset admin:
curl -X POST -H "Content-Type: application/json" https://scan.sv-1.dev.global.canton.network.sync.global/registry/transfer-instruction/v1/transfer-factory -d '{
"choiceArguments" : {
"expectedAdmin" : "DSO::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a",
"transfer" : {
"sender" : "SENDER_PARTY_ID",
"receiver" : "RECEIVER_PARTY_ID",
"amount" : "1000.0",
"instrumentId" : {
"admin" : "DSO::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a",
"id" : "Amulet"
},
"requestedAt" : "2025-07-11T12:45:00Z",
"executeBefore" : "2025-07-12T12:45:00Z",
"inputHoldingCids" : [
"YOUR_CHOICE_OF_INPUT_CIDs"
],
"meta" : { "values" : {} }
},
"extraArgs" : {
"context": { "values" : {} },
"meta" : { "values" : {} }
}
}
}'
# Example output:
# {
# "factoryId": "009f00e5bf0…", – ContractId of the transferfactory to use
# "transferKind": "direct", – type of transfer - see pre-approvals for more information
# "choiceContext": { … }, – data to stick in the extra arguments
# "disclosedContracts": [ … ] – any admin-private contracts on chain needed for preparation
# }
# 4. The information obtained can be used to construct the transfer and transaction in the prepare step:
#
# Transfer:
# "transfer" : {
# "sender" : "SENDER_PARTY_ID",
# "receiver" : "RECEIVER_PARTY_ID",
# "amount" : "1000.0",
# "instrumentId" : {
# "admin" : "DSO::1220b1431ef217342db44d516bb9befde802be7d8899637d290895fa58880f19accc",
# "id" : "Amulet"
# },
# "requestedAt" : "2025-08-11T12:45:00Z",
# "executeBefore" : "2025-08-12T12:45:00Z",
# "inputHoldingCids" : [
# "YOUR_CHOICE_OF_INPUT_CIDs"
# ],
# "meta" : { "values" : {} }
# }
```
Preparing the 交易
Now that we have a 命令 we need to prepare the 交易 by calling a node’s RPC API which will return an unsigned 交易. It must be a validator which hosts the party initiating the 交易 as private information is needed to construct the 交易. This is unlike other chains where you construct the 交易 fully offline using an SDK. A 交易 is a collection of 命令 that are atomic, meaning that either all 命令 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 交易 we need to send the 命令 to the ledger.
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
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,
})
}
```
```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# In the example below, replace the values with your own
# PrepareTransaction call with all the inputs gathered.
curl -X POST http://YOUR_NODE_JSON_API/v2/interactive-submission/prepare -d {
"userId" : "USER_ID",
"commandId" : "curl-transfer-test",
"actAs" : ["SENDER_PARTY_ID"],
"readAs" : [],
"synchronizerId": "global-domain::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a",
"verboseHashing": false,
"packageIdSelectionPreference" : [],
"commands" : [ {
"ExerciseCommand" : {
"templateId" : "#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory",
"contractId" : "009f00e5bf0…",
"choice" : "TransferFactory_Transfer",
"choiceArgument" : {
... ,
"extraArgs" : {
"context": { ... },
...
}
}
}
} ],
"disclosedContracts" : [ … ]
}
```
The return type is an unsigned 交易 if the combination of the 命令 are possible, otherwise an 错误 is returned. The 交易 can then be visualised and signed by the party.
Validating the 交易
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 交易 is returned alongside with the hash that needs to be signed. 若 validator is not controlled by you, then it might be a good idea to validate that the 交易 is what you expect it to be. 你可以 use the 钱包 SDK to visualize the 交易 as described in the Visualizing a 交易 section.
On top of visualizing the 交易, it’s also important to compute the 交易 hash yourself and confirm that it matches the hash of the 交易 provided by the validator from the prepare step.
The hash can be computed using the 钱包 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')
}
你可以 then compare the hash with the 交易.preparedTransactionHash to ensure they match.
Signing the 交易
Once the 交易 is validated, the hash retrieved from the prepare step can be signed using the private key of the party.
Below 展示 an example in the 钱包 SDK and using cURL 命令:
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
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)
}
```
```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# In this example the hash is signed using openssl and "PREPARE_TRANSACTION_RESPONSE.json" is the JSON output from the prepare
# transaction step is here. "PRIVATE_KEY_FILE" should be the private key of the namespace of the external party. For more information
# on the openssl commands to generate the key see here:
TRANSACTION_HASH=$(cat create_ping_prepare_response.json | jq -r .prepared_transaction_hash)
PREPARED_TRANSACTION=$(cat create_ping_prepare_response.json | jq -r .prepared_transaction)
SIGNATURE=$(echo -n "$TRANSACTION_HASH" | base64 --decode | openssl pkeyutl -rawin -inkey "PRIVATE_KEY_FILE" -keyform DER -sign | openssl base64 -e -A)
```
Submitting the 交易
Once the 交易 is signed, it can be executed on the validator. 你可以 observe completions by seeing the committed 交易. If they don’t appear on your ledger, you are guaranteed some 响应, and you can keep retrying; signed 交易 are idempotent. Finality usually takes 3-10s.
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
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 })
}
```
```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl http://localhost:7575/v2/interactive-submission/execute -d {
"preparedTransaction": "$PREPARED_TRANSACTION",
"hashingSchemeVersion": "HASHING_SCHEME_VERSION_V2",
"userId": "USER_ID",
"submissionId": "51dd5a0e-2ab6-4ca4-aa9d-9333fb603eb0",
"deduplicationPeriod": {
"Empty": {}
},
"partySignatures": {
"signatures": [
{
"party": "PARTY_ID",
"signatures": [
{
"format": "SIGNATURE_FORMAT_CONCAT",
"signature": "$SIGNATURE",
"signingAlgorithmSpec": "SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_256",
"signedBy": "FINGERPRINT"
}
]
}
]
}
}
```
Observing the 交易
The execute 方法 in the ledger`` namespace will execute the submission and wait for a 响应. THs returns an \updateIdandcompletionOffset`. Additionally, you can continuously monitor 持仓 changes using token standard history parser.
How to use the SDK to Offline sign a 交易
The SDK exposes functionality that can be used in an offline environment to sign and validate 交易 the below script 展示 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')
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/signing-交易-from-dapps/index.rst” hash=“6593e378” */}
Signing 交易 from third party dApps
A normal flow on blockchain 应用 is to have dApps that interact with the blockchain on the clients behalf, these flows usually require the 用户 to sign 交易 that the dApp prepares and submit it. To faciliate this in Canton it is required that the prepared 交易 is sent to the 钱包 for signing. An easy way of supporting this is to expose a dApp API (OpenRPC spec can be found here: https://github.com/canton-网络/钱包/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 钱包 提供方 dApp API by either embedding a 钱包 提供方 in the dApp or by connecting to an external 钱包 提供方 via a browser extension or other means. Then the dApp is able to funnel 交易 through to the 钱包 提供方 for signing.
Receiving a 交易
A dApp would usually call the prepareExecute 端点 or the prepareExecuteAndWait 端点. In both cases the 钱包 提供方 would prepare, sign and submit the 交易 to the ledger.
你可以 prepare the incoming 交易 using the 钱包 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 preparedCommand = global.PREPARED_COMMAND
const myParty = global.EXISTING_PARTY_2
sdk.ledger.prepare({
partyId: myParty,
commands: preparedCommand,
})
}
Reading and Visualising the 交易
It is important when integrating with third party dApps to showcase the 用户 exactly what is being signed. Once the signature is applied the 交易 can be considered valid (and executed). The easiest would be to create a visualizer that takes a JSON representation of the 交易. The Json for a prepared 交易 (before signature is applied) can be obtained using the 钱包 SDK:
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/token-standard/index.rst” hash=“3c8d8496” */}
Token Standard
The 钱包 SDK support performing basic token standard operations, these are exposed through the sdk.tokenStandard a complete overview of the underlying 集成 can be found here and the CIP is defined here.
How do i quickly perform a transfer between two Party?
The below performs a 2-step transfer between Alice and Bob and expose their 持仓:
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import _accept from './_accept.js'
import { TransferTestScriptParameters } from './types.js'
import _reject from './_reject.js'
import _withdraw from './_withdraw.js'
import _expire from './_expire.js'
import {
TOKEN_NAMESPACE_CONFIG,
TOKEN_PROVIDER_CONFIG_DEFAULT,
AMULET_NAMESPACE_CONFIG,
} from '../utils/index.js'
const logger = pino({ name: 'v1-02-two-step-transfer', 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-02-alice',
})
.sign(senderKeys.privateKey)
.execute()
const receiverKeys = sdk.keys.generate()
const receiver = await sdk.party.external
.create(receiverKeys.publicKey, {
partyHint: 'v1-02-bob',
})
.sign(receiverKeys.privateKey)
.execute()
const [amuletTapCommand, amuletTapDisclosedContracts] = await sdk.amulet.tap(
sender.partyId,
'10000'
)
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 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')
}
const transferTestScriptParameters: TransferTestScriptParameters = {
sdk,
sender,
senderKeys,
receiver,
receiverKeys,
logger,
}
await _accept(transferTestScriptParameters)
await _reject(transferTestScriptParameters)
await _withdraw(transferTestScriptParameters)
await _expire(transferTestScriptParameters)
```
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { localNetStaticConfig } from '@canton-network/wallet-sdk'
import { TransferTestScriptParameters } from './types.js'
export default async (args: TransferTestScriptParameters) => {
const { sdk, sender, receiver, senderKeys, receiverKeys, logger } = args
const [transferCommand, transferDisclosedContracts] =
await sdk.token.transfer.create({
sender: sender.partyId,
recipient: receiver.partyId,
instrumentId: 'Amulet',
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
amount: '2000',
})
logger.info('Transfer command created, ready for signing and execution')
await sdk.ledger
.prepare({
partyId: sender.partyId,
commands: transferCommand,
disclosedContracts: transferDisclosedContracts,
})
.sign(senderKeys.privateKey)
.execute({ partyId: sender.partyId })
logger.info(
{ sender, receiver },
'Submitted transfer command from Sender to Receiver'
)
const receiverPendingTransfers = await sdk.token.transfer.pending(
receiver.partyId
)
logger.info(
receiverPendingTransfers,
'Receiver pending transfer instructions'
)
const [acceptCommand, acceptDisclosedContracts] =
await sdk.token.transfer.accept({
transferInstructionCid: receiverPendingTransfers[0].contractId,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: receiver.partyId,
commands: acceptCommand,
disclosedContracts: acceptDisclosedContracts,
})
.sign(receiverKeys.privateKey)
.execute({ partyId: receiver.partyId })
logger.info('Receiver accepted the transfer instruction')
const receiverUtxos = await sdk.token.utxos.list({
partyId: receiver.partyId,
})
logger.info(
receiverUtxos,
'Receiver UTXOs after accepting transfer instruction'
)
const receiverAmuletUtxos = receiverUtxos.filter((utxo) => {
return (
utxo.interfaceViewValue.amount === '2000.0000000000' &&
utxo.interfaceViewValue.instrumentId.id === 'Amulet'
)
})
if (receiverAmuletUtxos.length === 0) {
throw new Error(
'No Amulet UTXOs found for Receiver after accepting transfer instruction'
)
}
logger.info('Two step transfer process completed successfully')
}
```
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { localNetStaticConfig } from '@canton-network/wallet-sdk'
import { TransferTestScriptParameters } from './types.js'
export default async (args: TransferTestScriptParameters) => {
const { sdk, receiver, sender, senderKeys, receiverKeys, logger } = args
const [transferCommand, transferDisclosedContracts] =
await sdk.token.transfer.create({
sender: sender.partyId,
recipient: receiver.partyId,
instrumentId: 'Amulet',
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
amount: '2000',
})
logger.info('Transfer command created, ready for signing and execution')
await sdk.ledger
.prepare({
partyId: sender.partyId,
commands: transferCommand,
disclosedContracts: transferDisclosedContracts,
})
.sign(senderKeys.privateKey)
.execute({ partyId: sender.partyId })
logger.info(
{ sender, receiver },
'Submitted transfer command from Sender to Receiver'
)
const pendingTransfer = await sdk.token.transfer.pending(receiver.partyId)
if (!pendingTransfer.length) throw Error('pendingTransfer is empty')
const [rejectCommand, rejectDisclosedContracts] =
await sdk.token.transfer.reject({
transferInstructionCid: pendingTransfer[0].contractId,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: receiver.partyId,
commands: rejectCommand,
disclosedContracts: rejectDisclosedContracts,
})
.sign(receiverKeys.privateKey)
.execute({
partyId: receiver.partyId,
})
const pendingTransferAfterReject = await sdk.token.transfer.pending(
receiver.partyId
)
if (pendingTransferAfterReject.length)
throw Error('pendingTransferAfterReject is not empty')
logger.info('Successfully rejected the submitted transfer')
}
```
```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { localNetStaticConfig } from '@canton-network/wallet-sdk'
import { TransferTestScriptParameters } from './types.js'
export default async (args: TransferTestScriptParameters) => {
const { sdk, receiver, sender, senderKeys, logger } = args
const [transferCommand, transferDisclosedContracts] =
await sdk.token.transfer.create({
sender: sender.partyId,
recipient: receiver.partyId,
instrumentId: 'Amulet',
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
amount: '2000',
})
logger.info('Transfer command created, ready for signing and execution')
await sdk.ledger
.prepare({
partyId: sender.partyId,
commands: transferCommand,
disclosedContracts: transferDisclosedContracts,
})
.sign(senderKeys.privateKey)
.execute({ partyId: sender.partyId })
logger.info(
{ sender, receiver },
'Submitted transfer command from Sender to Receiver'
)
const pendingTransfer = await sdk.token.transfer.pending(receiver.partyId)
if (!pendingTransfer.length) throw Error('pendingTransfer is empty')
const [withdrawCommand, withdrawDisclosedContracts] =
await sdk.token.transfer.withdraw({
transferInstructionCid: pendingTransfer[0].contractId,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: sender.partyId,
commands: withdrawCommand,
disclosedContracts: withdrawDisclosedContracts,
})
.sign(senderKeys.privateKey)
.execute({
partyId: sender.partyId,
})
const pendingTransferAfterWithdraw = await sdk.token.transfer.pending(
receiver.partyId
)
if (pendingTransferAfterWithdraw.length)
throw Error('pendingTransferAfterWithdraw is not empty')
logger.info('Successfully withdrawn the submitted transfer')
}
```
Listing 持仓 (UTXO’s)
Canton uses created and archived 事件 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 合约 with the interface ‘Holding’ to see all assets you posses across different instruments.
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 myParty = global.EXISTING_PARTY_1
await sdk.token.utxos.list({ partyId: myParty })
}
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 交易
In order to stream 交易 事件 as they happen on ledger the listHoldingTransactions 端点 can be used. This takes two ledger 偏移 and gives an overview of all token standard 交易 that have happened between. It also returns a nextOffset that can be used when calling the 端点 again. This will allow you to easily ensure you do not receive any 交易 twice and you are only querying the 交易 that have happened after.
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 myParty = global.EXISTING_PARTY_1
await sdk.token.holdings({ partyId: myParty })
}
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 方法 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 { 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,
amulet: global.AMULET_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_1
await sdk.amulet.tap(myParty, '2000')
}
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 端点, sign the returned hash and finally executeSubmission.
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),
})
}
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 交易 should automatically expire.
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
await sdk.token.transfer.create({
sender,
recipient: receiver,
amount: '2000',
instrumentId: 'Amulet',
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
}
if we call sdk.token.utxos.list({partyId}) or sdk.token.utxos.list({partyId, includeLocked: false}) then it will show 1 utxo of 50 (then one we excluded). This defaults to filtering out the locked utxos.
if we call sdk.token.utxos.list({partyId, includeLocked: 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(CC) supports setting up a “转账 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 { 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,
amulet: global.AMULET_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_1
const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey
const createPreapprovalCommand =
await sdk.amulet.preapproval.command.create({
parties: {
receiver: myParty,
},
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: createPreapprovalCommand,
})
.sign(myPrivateKey)
.execute({
partyId: myParty,
})
}
Accepting or rejecting a 2-step 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 钱包 SDK.
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 myParty = global.EXISTING_PARTY_1
//this returns a list of all transfer instructions, you can then accept or reject them
await sdk.token.transfer.pending(myParty)
}
the above give a list of pending transfer instructions, you can then exercise the accept or reject choice on them:
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 myParty = global.EXISTING_PARTY_2
const myPrivateKey = global.EXISTING_PARTY_2_KEYS.privateKey
const Reject = true
const myPendingTransaction = await sdk.token.transfer.pending(myParty)
const myPendingTransactionCid = myPendingTransaction[0].contractId
if (Reject) {
//reject the transaction
const [rejectTransferCommand, disclosedContracts] =
await sdk.token.transfer.reject({
transferInstructionCid: myPendingTransactionCid,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: rejectTransferCommand,
disclosedContracts: disclosedContracts,
})
.sign(myPrivateKey)
.execute({ partyId: myParty })
} else {
//accept the transaction
const [acceptTransferCommand, disclosedContracts] =
await sdk.token.transfer.accept({
transferInstructionCid: myPendingTransactionCid,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: acceptTransferCommand,
disclosedContracts: disclosedContracts,
})
.sign(myPrivateKey)
.execute({ partyId: myParty })
}
}
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 { 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 myParty = global.EXISTING_PARTY_1
const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey
const myPendingTransaction = await sdk.token.transfer.pending(myParty)
const myPendingTransactionCid = myPendingTransaction[0].contractId
const [withdrawTransferCommand, disclosedContracts] =
await sdk.token.transfer.withdraw({
transferInstructionCid: myPendingTransactionCid,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: withdrawTransferCommand,
disclosedContracts: disclosedContracts,
})
.sign(myPrivateKey)
.execute({ partyId: myParty })
}
How do i quickly setup 转账预批准(TransferPreapproval)?
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 转账预批准(TransferPreapproval) for Bob and performs a 1-step transfer from Alice to Bob:
import { Holding, PrettyContract } from '@canton-network/core-tx-parser'
import { localNetStaticConfig, SDK } 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 logger = pino({ name: 'v1-05-preapproval', 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,
})
await sdk.amulet.tapInternal('1000')
const aliceKeys = sdk.keys.generate()
const alice = await sdk.party.external
.create(aliceKeys.publicKey, {
partyHint: 'v1-05-alice',
})
.sign(aliceKeys.privateKey)
.execute()
const [amuletTapCommand, amuletTapDisclosedContracts] = await sdk.amulet.tap(
alice.partyId,
'10000'
)
await sdk.ledger
.prepare({
partyId: alice.partyId,
commands: amuletTapCommand,
disclosedContracts: amuletTapDisclosedContracts,
})
.sign(aliceKeys.privateKey)
.execute({ partyId: alice.partyId })
const bobKeys = sdk.keys.generate()
const bob = await sdk.party.external
.create(bobKeys.publicKey, {
partyHint: 'v1-05-bob',
})
.sign(bobKeys.privateKey)
.execute()
// --- TEST CREATE COMMAND
const createPreapprovalCommand = await sdk.amulet.preapproval.command.create({
parties: {
receiver: bob.partyId,
},
})
logger.info(
{ createPreapprovalCommand },
'Successfully created a preapproval command'
)
await sdk.ledger
.prepare({
partyId: bob.partyId,
commands: createPreapprovalCommand,
})
.sign(bobKeys.privateKey)
.execute({
partyId: bob.partyId,
})
logger.info('Successfully registered the preapproval.')
// --- TEST FETCH
const start = performance.now()
const fetchOnceStatus = await sdk.amulet.preapproval.fetchQuick(bob.partyId)
const end = performance.now()
const duration = end - start
if (duration < 1000) {
logger.info(
`Success! The operation was fast (${duration.toFixed(2)} ms) and fetchOnce status is ${fetchOnceStatus}.`
)
} else {
logger.warn(
`Warning: Operation took longer than 1 second (${(duration / 1000).toFixed(2)} s).`
)
}
logger.info('Fetching for preapproval status with retry')
const fetchedPreapprovalStatus = await sdk.amulet.preapproval.fetchStatus(
bob.partyId
)
logger.info({ fetchedPreapprovalStatus }, 'Fetched preapproval status')
const sentValue = 2000
const [transferCommand, transferDisclosedContracts] =
await sdk.token.transfer.create({
sender: alice.partyId,
recipient: bob.partyId,
amount: sentValue.toString(),
instrumentId: 'Amulet',
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: alice.partyId,
commands: transferCommand,
disclosedContracts: transferDisclosedContracts,
})
.sign(aliceKeys.privateKey)
.execute({ partyId: alice.partyId })
logger.info({ sentValue }, 'Executed transfer from Alice to Bob with value:')
const aliceUtxos = await sdk.token.utxos.list({ partyId: alice.partyId })
const bobUtxos = await sdk.token.utxos.list({ partyId: bob.partyId })
const partyAmuletValue = (utxos: PrettyContract<Holding>[]) =>
utxos.reduce(
(acc, utxo) => acc + parseFloat(utxo.interfaceViewValue.amount),
0
)
const aliceAmuletValue = partyAmuletValue(aliceUtxos)
const bobAmuletValue = partyAmuletValue(bobUtxos)
if (aliceAmuletValue !== 8000 || bobAmuletValue !== 2000)
throw Error(
`Wrong end results for utxos: ${JSON.stringify({ aliceAmuletValue, bobAmuletValue })}`
)
logger.info({ aliceAmuletValue, bobAmuletValue }, 'Result:')
// --- TEST RENEW COMMAND
logger.info('Renewing preapproval...')
const start2 = performance.now()
const fetchOnceStatusWithPreapproval = await sdk.amulet.preapproval.fetchQuick(
bob.partyId
)
const end2 = performance.now()
const duration2 = end2 - start2
if (duration < 1000) {
logger.info(
`Success! The operation was fast (${duration2.toFixed(2)} ms) and fetchOnce status is ${fetchOnceStatusWithPreapproval}.`
)
} else {
logger.warn(
`Warning: Operation took longer than 1 second (${duration2.toFixed(2)} s).`
)
}
const newExpiresAt = new Date(fetchedPreapprovalStatus!.expiresAt)
newExpiresAt.setDate(newExpiresAt.getDate() + 2)
await sdk.amulet.preapproval.renew({
parties: {
receiver: bob.partyId,
},
expiresAt: newExpiresAt,
})
const fetchedStatusAfterRenew = await sdk.amulet.preapproval.fetchStatus(
bob.partyId,
{
oldCid: fetchedPreapprovalStatus!.contractId,
}
)
const before = fetchedPreapprovalStatus!.expiresAt
const after = fetchedStatusAfterRenew!.expiresAt
if (!(after.getTime() > before.getTime())) {
throw new Error(
`Expected expiresAt to increase after renewal. before=${fetchedPreapprovalStatus!.expiresAt.toISOString()} after=${fetchedStatusAfterRenew!.expiresAt.toISOString()}`
)
}
logger.info(
{
before: before.toISOString(),
after: after.toISOString(),
extendedSeconds: Math.round(
(after.getTime() - before.getTime()) / 1000
),
},
'TransferPreapproval expiry extended, managed to renew preapproval'
)
// --- TEST CANCEL COMMAND
logger.info('Testing out cancel command')
if (!fetchedStatusAfterRenew?.templateId) {
throw new Error('No preapproval found - fetchedPreapprovalStatus is null')
}
const [cancelPreapprovalCommand, cancelDisclosedContracts] =
await sdk.amulet.preapproval.command.cancel({
parties: {
receiver: bob.partyId,
},
})
if (!cancelPreapprovalCommand) {
throw Error(
'Cancel preapproval command is null even though one has been created before'
)
}
await sdk.ledger
.prepare({
partyId: bob.partyId,
commands: cancelPreapprovalCommand,
disclosedContracts: cancelDisclosedContracts,
})
.sign(bobKeys.privateKey)
.execute({
partyId: bob.partyId,
})
logger.info('Submitted cancel command; now polling')
const cancelled = await sdk.amulet.preapproval.fetchStatus(bob.partyId, {
cancelled: true,
})
const preapprovalACS = await sdk.ledger.acsReader.readJsContracts({
parties: [bob.partyId],
filterByParty: true,
})
const renewedPreapprovalStillActive = preapprovalACS.some(
(contract) => contract.contractId === fetchedStatusAfterRenew?.contractId
)
if (cancelled === null && !renewedPreapprovalStillActive) {
logger.info(`Successfully cancelled`)
}
How to renew or cancel a 转账预批准(TransferPreapproval)
若你 have used the validator operator party as the 提供方, then it will automatically renew the 转账预批准(TransferPreapproval) approximately 20 days before expiry, however there are cases where you would like to perform the preapproval renewal manually:
await amulet.preapproval.renew({
parties: {
receiver: myPartyId,
},
expiresAt: newExpiresAt,
})
你可以 also deploy a secondary 转账预批准(TransferPreapproval), however this means that there are simply two preapprovals instead of it replacing the existing.
若你 have accidentally created a 转账预批准(TransferPreapproval) that you dont want to keep you can perform a cancel instead:
const [cancelPreapprovalCommand, cancelDisclosedContracts] =
await amulet.preapproval.command.cancel({
parties: {
receiver: myPartyId,
},
})
await sdk.ledger
.prepare({
partyId: myPartyId,
commands: cancelPreapprovalCommand,
disclosedContracts: cancelDisclosedContracts,
})
.sign(myPrivateKey)
.execute({
partyId: myPartyId,
})
How do I fetch 交易 by updateId?
Given an update Id, the token namespace has a 方法 for getting a 交易 based on the updateId. This will print out the 交易 in the same format as sdk.token.持仓
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,
amulet: global.AMULET_NAMESPACE_CONFIG,
token: global.TOKEN_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_1
const [amuletTapCommand, amuletTapDisclosedContracts] =
await sdk.amulet.tap(myParty, '2000')
const result = await sdk.ledger
.prepare({
partyId: myParty,
commands: amuletTapCommand,
disclosedContracts: amuletTapDisclosedContracts,
})
.sign(global.EXISTING_PARTY_1_KEYS.privateKey)
.execute({ partyId: myParty })
await sdk.token.transactionsById({
updateId: result.updateId,
partyId: myParty,
})
}
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/钱包-sdk-配置/index.rst” hash=“75ca5982” */}
钱包 SDK 配置
若你 have already played around with the 钱包 SDK you might have come across snippets like:
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')
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 配置:
import { SDK, localNetStaticConfig } from '@canton-network/wallet-sdk'
export default async function () {
const sdk = await SDK.create({
auth: {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: 'ledger-api-user',
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
},
ledgerClientUrl: new URL('http://localhost:2975'),
token: {
validatorUrl: new URL('http://localhost:2000/api/validator'),
registries: [
new URL('http://localhost:2000/api/validator/v0/scan-proxy'),
],
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
},
amulet: {
validatorUrl: localNetStaticConfig.LOCALNET_APP_VALIDATOR_URL,
scanApiUrl: localNetStaticConfig.LOCALNET_SCAN_API_URL,
auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
},
asset: {
registries: [localNetStaticConfig.LOCALNET_REGISTRY_API_URL],
auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
},
})
const myParty = global.EXISTING_PARTY_1
await sdk.token.utxos.list({ partyId: myParty })
await sdk.amulet.traffic.status()
// OR, you can defer loading config by calling .extend()
const basicSDK = await SDK.create({
auth: {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: 'ledger-api-user',
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
},
ledgerClientUrl: new URL('http://localhost:2975'),
})
// Extend with token namespace
const tokenExtendedSDK = await basicSDK.extend({
token: {
validatorUrl: new URL('http://localhost:2000/api/validator'),
registries: [
new URL('http://localhost:2000/api/validator/v0/scan-proxy'),
],
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
},
})
// Now token namespace is available
await tokenExtendedSDK.token.utxos.list({ partyId: myParty })
// Can extend further with more namespaces
const fullyExtendedSDK = await tokenExtendedSDK.extend({
amulet: {
validatorUrl: localNetStaticConfig.LOCALNET_APP_VALIDATOR_URL,
scanApiUrl: localNetStaticConfig.LOCALNET_SCAN_API_URL,
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
},
})
// Now both token and amulet are available
await fullyExtendedSDK.token.utxos.list({ partyId: myParty })
await fullyExtendedSDK.amulet.traffic.status()
}
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 命令 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.4.12-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": {}
}
}
},
"packageFeature": {
"maxVettedPackagesPageSize": 100
}
}
}
the fields may vary based on your 配置.
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 an 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(CC) 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
The 钱包-sdk can either take in a 提供方 (which will have auth bundled into it) or a LedgerClientUrl + TokenProviderConfig. In our examples, we have provided a default TokenProviderConfig for connecting to localnet, which uses a self-signed token.
{
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: 'ledger-api-user',
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
}
The value for some of the audiences in localnet would have to be adjusted to match “https://canton.网络.global”. This is specifically the LEDGER_API_AUTH_AUDIENCE & VALIDATOR_AUTH_AUDIENCE.
When upgrading your setup from a localnet setup to a 生产 or client facing environment then it might make more sense to add proper 认证 to the ledger api and other 服务. The community contributions include okta and keycloak OIDC. These can easily be configured for the SDK using a different TokenProviderConfig. 以下 programmatic 方法 of token fetching are supported:
- `static`: a fixed, in-memory token. Only used for compatibility, it will totally break for expired tokens.
- `self_signed`: only for 开发 purposes, used for Canton setups that accept HMAC256 self signed tokens.
- `client_credentials`: used to programmatically acquire tokens via oauth2, a.k.a “machine-to-machine” tokens
export type TokenProviderConfig =
| {
method: 'static'
token: string
}
| {
method: 'self_signed'
issuer: string
credentials: ClientCredentials
}
| {
method: 'client_credentials'
configUrl: string
credentials: ClientCredentials
}
export interface ClientCredentials {
clientId: string
clientSecret: string
scope: string | undefined
audience: string | undefined
}
Registering Plugins
The 钱包 SDK supports extending its functionality through a plugin system. Plugins allow you to add custom 方法 and functionality to the SDK instance while maintaining access to the SDK context and logger.
Creating and Registering a Plugin
To create a plugin, extend the SDKPlugin class and implement your custom functionality. Plugins are registered using the registerPlugins 方法, which accepts a record of plugin constructors keyed by their desired property names.
import { SDK, SDKContext, SDKPlugin } from '@canton-network/wallet-sdk'
export default async function () {
const sdk = (
await SDK.create({
auth: {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: 'ledger-api-user',
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
},
ledgerClientUrl: 'http://localhost:2975',
})
).registerPlugins({
myPlugin: class extends SDKPlugin {
// wallet-sdk plugin should always accept SDKContext
constructor(protected readonly ctx: SDKContext) {
super('myPlugin', ctx)
}
myMethod() {
// do some logic
return
}
},
})
sdk.myPlugin.myMethod()
}
Key Points
- Plugin Constructor: Plugin classes must accept
SDKContextas a constructor parameter and pass it to thesuper()call along with the plugin name. - 类型 Safety: The
registerPlugins方法 provides full type safety, ensuring that registered plugins are accessible with proper autocompletion and type checking. - Access to SDK Context: Plugins have access to the SDK’s context, logger, and other internal utilities through the
ctxproperty. - Multiple Plugins: 你可以 register multiple plugins at once by passing them in a single object to
registerPlugins.
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/流量/index.rst” hash=“bc045f4b” */}
流量
Below is a high-level summary of the 同步器 流量 Fees page in the Splice 验证者 documentation. 更多信息见 detail on point, it’s advised to read that documentation.
流量
-
流量 fees are paid at the 验证者节点 level, not the party level.
-
Every validator has a 流量 余额 at the global 同步器 level.
- 流量 is measured in bytes.
- A trickle rate of free 流量 is provided to 验证者节点 every 10 minutes (each mining round).
-
流量 is deducted from your 验证者节点’s 流量 余额 every time your node sends a message to the 同步器. 流量 is charged for:
- Broadcasting a 交易 - this is where the bulk of the 流量 fees will be paid.
- Sending consensus messages for 交易 a validator is involved in.
-
If your node runs out of 流量 it is unable to transact. It’ll recover by itself thanks to the free trickle rate. However, you can buy more 流量. 参见 next section.
Getting more 流量
-
流量 is obtained by burning Canton Coin(CC) and it is always pre-purchased.
-
The conversion Canton Coin(CC) <> Bytes can be derived from on-chain parameters.
- 超级验证者(SV) publish an on-chain conversion CC <> USD.
- 超级验证者(SV) publish a 流量 cost USD <> Bytes.
-
Anyone can burn Canton Coin(CC) to get 流量 for any node.
- 你可以 buy your own 流量.
- 你可以 sign up with a 服务 like the Denex Gas Station to buy your 流量.
-
The 验证者节点 has automation to keep 流量 topped up. As long as you keep CC in your validator party, it’ll stay available. 参见 here for how to configure automatic 流量 purchases.
How to determine the 流量 cost of a 交易?
Follow this FAQ entry in the Splice documentation.
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/tokenomics-and-奖励/index.rst” hash=“08ff71a4” */}
Tokenomics and 奖励
CC 奖励
- The tokenomics operate on 10m “mining rounds”.
- Every 10 minutes, different stakeholders of the 网络 are rewarded with coupons which can be used to mint Canton Coin(CC) according to how much value they’ve brought to the 网络.
- Coupons are rewarded to the 验证者 admin party.
- All 奖励 awarded to a node’s local Party will be auto-minted by the node administrator party.
- The validators automation is not able to mint the 奖励 for an external Party - the external party needs to delegate the ability for the validator admin party to mint their 奖励 on their behalf or manually mint the 奖励 themselves each round they receive 奖励.
- All 奖励 and coupons are mintable the follow mining round
- If 奖励 are not redeemed then they are lost*
你可以 find more information about the tokenomics of Canton Coin(CC) here.
Ways of Obtaining Canton Coin(CC) 奖励
The tokenomics of the 网络 give you options for obtaining Canton Coin(CC):
验证者 & 超级验证者 Liveness 奖励
Just for being online and growing the 网络, Canton Coin(CC) tokenomics enable validator operators to mint CC. 验证者 and 超级验证者(SV) generate reward coupons that can be used to mint Canton Coin(CC)s. The coupons are paid out to the validator adminstration party. For local Party onboarded to a validator, the validator 应用 runs background automation to mint all activity records automatically. An external party signs 交易 using a key they control. As a consequence, the validator automation is not able to perform minting for external Party.
For external Party, automation needs to be developed to call AmuletRules_Transfer at least once per round with all activity records as inputs.
你可以 find more information about the tokenomics of Canton Coin(CC) at /overview/reference/canton-coin-tokenomics.
All 奖励 and coupons are mintable the follow mining round, if 奖励 are not redemed then they are lost
验证者 Activeness 奖励
若你 self-purchase 流量, you get a discount via these 奖励.
应用 奖励 and Featured Activity Markers
应用 奖励
- 交易 which include Canton Coin(CC) and featured 应用 交易 earn 应用 奖励.
- The percentage of Canton Coin(CC) awarded to 应用 is significant and will grow over time.
- The current amount of CC awarded to 应用 can be seen in the ‘Canton Coin(CC) Reward Split By 角色 Over Time’ chart here.
Featured 应用 activity markers
- 应用 which generate valuable activity for the canton 网络, and have ‘featured 应用’ status can earn more 应用 奖励.
- By qualifying as a Featured 应用 (apply here) and applying a
FeaturedAppActivityMarkerto a 交易 it is marked and converted to reward coupons that can be redeemed. - A weighting is applied to each 交易 in that Canton Coin(CC) minting round.
- More weightings in a round equate to more 应用 奖励.
- Currently, featured apps receive many more 奖励 in Canton Coin(CC) than the average 交易 costs in 流量 fees.
Gaining 应用 奖励 as a 钱包/Custodian/Exchange
- 请求 featured 应用 status. Apply here.
- On DevNet you can self-feature through the 钱包 UI.
Enabling Pre-approval / 1-step Transfers
- One way that wallets can earn app 奖励 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 用户 deposit funds into your 账户 you’ll receive 奖励.
- 你可以 also mark 交易 out of your 钱包 (that don’t go to Party which have 1-step transfers enabled) with your party as the operator part for that 交易
- It’s anticipated that you will receive far more Canton Coin(CC) through 奖励 for pre-approval deposits and transfers than you pay in 流量 fees, setting up pre-approvals and for creating Party.
- Therefore:
- You may not want to charge your 用户 for 流量 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 监控 or even controlling the number of Party that a 用户 can create so that you don’t end up with 用户 creating too many Party and therefore cost.
Redeming Reward Coupons with External Party
To accept 奖励 with an external party you need to call AmuletRules_Transfer with the activity records as inputs.
Sharing Featured 应用 Reward between multiple Party
Featured 应用 奖励 can be shared between multiple Party, 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.
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/用户-management/index.rst” hash=“35355c49” */}
用户 Management
The 钱包 SDK has functionality for creating and managing 用户 rights, by default when you are connecting it uses whichever 用户 is defined in your auth-controller. 若 用户 is an admin 用户 on the ledger api they can be used to create other 用户 and grant them rights.
How do I quickly setup canReadAsAnyParty and canExecuteAsAnyParty?
This script sets up three 用户 alice, bob and master. master is given canReadAsAnyParty and canExecuteAsAnyParty and it 展示 proper access control by creating Party and ensuring that alice and bob can not see each others Party.
import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import { TOKEN_PROVIDER_CONFIG_DEFAULT } from './utils/index.js'
const logger = pino({ name: 'v1-multi-user-setup', level: 'info' })
logger.info('Operator sets up users and primary parties')
const operatorSdk = await SDK.create({
auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
})
const aliceInternal = await operatorSdk.party.internal.allocate({
partyHint: 'v1-09-alice',
})
const bobInternal = await operatorSdk.party.internal.allocate({
partyHint: 'v1-09-bob',
})
const masterPartyInternal = await operatorSdk.party.internal.allocate({
partyHint: 'v1-09-master',
})
logger.info('Created the internal parties')
const aliceUser = await operatorSdk.user.create({
userId: 'alice-user',
primaryParty: aliceInternal,
userRights: {
participantAdmin: true,
},
})
const bobUser = await operatorSdk.user.create({
userId: 'bob-user',
primaryParty: bobInternal,
userRights: {
participantAdmin: true,
},
})
const masterUser = await operatorSdk.user.create({
userId: 'master-user',
primaryParty: masterPartyInternal,
userRights: {
participantAdmin: true,
},
})
logger.info('created the users')
if (!(aliceUser || bobUser || masterUser)) {
throw new Error(`One of the users was not created correctly`)
}
await operatorSdk.user.rights.grant({
userId: masterUser.id!,
userRights: {
canExecuteAsAnyParty: true,
canReadAsAnyParty: 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`
)
const aliceSdk = await SDK.create({
auth: {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: aliceUser.id,
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
},
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
})
const aliceKeyPair = aliceSdk.keys.generate()
const aliceExternal = await aliceSdk.party.external
.create(aliceKeyPair.publicKey, {
partyHint: 'v1-09-alice',
})
.sign(aliceKeyPair.privateKey)
.execute()
logger.info(`alice created external party`)
const bobSdk = await SDK.create({
auth: {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: bobUser.id,
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
},
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
})
const bobKeyPair = bobSdk.keys.generate()
const bobExternal = await bobSdk.party.external
.create(bobKeyPair.publicKey, {
partyHint: 'v1-09-bob',
})
.sign(bobKeyPair.privateKey)
.execute()
logger.info(`bob created external party`)
const masterUserSdk = await SDK.create({
auth: {
method: 'self_signed',
issuer: 'unsafe-auth',
credentials: {
clientId: masterUser.id,
clientSecret: 'unsafe',
audience: 'https://canton.network.global',
scope: '',
},
},
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
})
const masterWalletView = await masterUserSdk.party.list()
if (!masterWalletView?.find((p) => p === aliceExternal.partyId)) {
throw new Error('master user cannot see alice party')
}
if (!masterWalletView?.find((p) => p === bobExternal.partyId)) {
throw new Error('master user cannot see bob party')
}
const aliceWalletView = await aliceSdk.party.list()
logger.info(aliceWalletView)
if (aliceWalletView?.find((p) => p === bobExternal.partyId)) {
throw new Error('alice user can see bob party')
}
const bobWalletView = await bobSdk.party.list()
if (bobWalletView?.find((p) => p === aliceExternal.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.user.rights.grant({
userRights: {
readAs: [aliceExternal.partyId],
},
})
const bobWalletViewAfterGrantRights = await bobSdk.party.list()
if (!bobWalletViewAfterGrantRights?.find((p) => p === aliceExternal.partyId)) {
throw new Error('bob user cannot see alice party even with ReadAs rights')
}
const bobRightsAfterGrantRights = await bobSdk.user.rights.list()
logger.info(bobRightsAfterGrantRights, 'Bob user rights')
await bobSdk.user.rights.revoke({
userRights: {
readAs: [aliceExternal.partyId],
},
})
const bobWalletViewAfterRevokeRights = await bobSdk.party.list()
if (bobWalletViewAfterRevokeRights?.find((p) => p === aliceExternal.partyId)) {
throw new Error('bob user can see alice party even after revoking rights')
}
Creating a new 用户
Creating a new 用户 can be done using the adminLedger, this new 用户 can then be granted rights or can create new Party as needed.
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,
})
await sdk.user.create({
userId: 'alice-user',
primaryParty: global.EXISTING_PARTY_1,
})
}
ReadAs and ActAs limitations
Currently when allocating a new party we also grant ReadAs and ActAs rights for that party for the submitting 用户. This allows the 用户 to do the normal flows involved like preparing 交易 and executing those. There are performance issues if too many of these rights are assigned to the same 用户, in the case of a master 用户 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 方法 changes if you need to allocate a party without granting rights:
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 key = sdk.keys.generate()
const party = await sdk.party.external
.create(key.publicKey, { partyHint: 'my-party-without-rights' })
.sign(key.privateKey)
.execute({ grantUserRights: false }) //do not grant user actAs and readAs for the party
}
CanReadAsAnyParty
CanReadAsAnyParty gives a 用户 full information about any party on the ledger, if a 用户 is set up with this they will see: 1. All Party hosted on the ledger (multi-hosted and single hosted) 2. All 交易 happening involving a party on the ledger 3. 准备 交易 on behalf of any party
This will not grant information about Party hosted on other ledgers or their 交易.
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,
})
await sdk.user.rights.grant({
userRights: { canReadAsAnyParty: true },
})
}
The SDK automatically leverages this elevated permission for certain 端点 like listWallets.
CanExecuteAsAnyParty
CanExecuteAsAnyParty gives full execution rights for a party, this means that a 用户 with these rights can submit 交易 on behalf of a party hosted on the ledger.
This does not give the 用户 rights to move funds without a valid signature!
The setup is similar to the `CanReadAsAnyParty`:
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,
})
//optional arguments are idp and userId; if not provided, will use the default idp and extract the userId from the auth token
await sdk.user.rights.grant({
userRights: { canExecuteAsAnyParty: true },
})
}
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/canton-coin-specific-considerations/index.rst” hash=“45961707” */}
Canton Coin(CC) Specific Considerations
Handling Time-Bound Signatures (Canton Coin(CC))
If your 钱包 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(CC) 交易 operate on a strict 10-minute minting cycle. Unlike standard Daml 交易, Canton Coin(CC) transfers and acceptances must reference a specific OpenMiningRound 合约 to calculate 网络 fees and 奖励.
- A new
OpenMiningRoundis created every 10 minutes. - The 合约 remains active for approximately 20 minutes (the current round + overlap).
- The 问题: 若你 prepare a 交易 referencing Round A, but you do not sign and submit it before Round A expires, the 网络 will reject it.
Common 错误: If your 交易 exceeds this window, the API will return a 409 Conflict with the following 错误:
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 用户 need to receive Canton Coin(CC), but you cannot sign a 交易 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 合约. This authorizes the sending party (or a specific 提供方) to deposit funds immediately without requiring an interactive acceptance signature for every 交易.
To do this, create a Splice.钱包.TransferPreapproval 合约. The guide on how to create the pre-approval 合约 in the 钱包 SDK is here and the general information about Canton Coin(CC) Preapprovals is here. By implementing a preapproval 合约 the receiver doesn’t need to accept Canton Coin(CC) transfers sent to them as they are automatically accepted.
Solution 2: Use 命令 Delegation for Outgoing Transfers / Sending Funds
Use Case: Your 用户 need to send Canton Coin(CC), but the signing process (e.g., institutional custody approval) takes hours.
The Fix: Use 命令 Delegation - TransferCommand.
Instead of signing the transfer 交易 directly (which pins a short-lived Mining Round), the 用户 signs a long-living instruction to transfer funds.
How it works
- 用户 Signs Instruction: The 用户 signs a 交易 to create a
Splice.ExternalPartyAmuletRules.TransferCommand合约.- This 合约 does not reference a mining round.
- It can remain valid for up to 24 hours (or as defined by
expiresAt).
- Delegated Execution: Once this 命令 is on the ledger, a 超级验证者 (SV) or a delegate picks it up.
- Execution: The delegate executes the actual transfer. The delegate selects the current
OpenMiningRoundat the moment of execution, ensuring the 交易 succeeds regardless of how long ago the 用户 signed the instruction.
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/deposits-into-exchanges/index.rst” hash=“8d2784fe” */}
Sending 充值 to Exchanges
To enable deposits to be sent to specific 用户 账户 at exchanges, an 账户 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(CC) 钱包
In the Canton Coin(CC) 钱包, the “说明” field in the screenshot below must be used to communicate this 账户 identifier in the format required by the exchange. 例如: “AcmeExchange 账户: <exchangeInternalAccountId>”.
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 “说明” or “Reason” field analogous to the Canton Coin(CC) 钱包, and store its value in the metadata field of the 转账 specification (code) when initiating a transfer. This is actually what the Canton Coin(CC) 钱包 does behind the scenes when initiating a Canton Coin(CC) 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 交易 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 命令 line argument and store it in the metadata field.
CN Token Registries
Token standard compliant registries must ensure that they pass the 转账 specification unchanged along when implementing multi-step transfers using the TransferInstruction interface (code).
{/* COPIED_START source=“splice-wallet-kernel:docs/wallet-integration-guide/src/usdcx-support/index.rst” hash=“852eb3cf” */}
USDCx Support for Wallets
概览
Circle and Digital Asset have partnered to develop and implement a USDC token on Canton Network. This implementation requires 用户 to send USDC on L1 chains (starting with Ethereum) to Circle’s xReserve 合约 which is then created as a USDC token on Canton Network. Conversely a withdrawal 请求 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 交易.
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 服务.
钱包 提供方 and exchanges have three options for supporting USDCx on the Canton Network:
-
转账 & hold USDCx - Since USDCx is a token standard (CIP-56) compliant asset then as such any 钱包 that supports the token standard will have built in support for transfers and holding.
-
Support xReserves deposits and withdrawals - Custom API 集成 is required for 钱包 提供方 and exchanges to support xReserve deposits and withdrawals to the utility-bridge daml models to / from their Party using the xReserves UI. Instructions for doing this are included in the section “Supporting xReserve 充值 and 提现” below.
-
Integrating the xReserve UI (Ethereum) into the 钱包 - To enable a full end-to-end experience for the 用户, a 钱包 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 交易 that must be submitted to an ethereum node:
- approve a USDC spending allowance.
- depositToRemote to deposit USDC into the xReserve 合约.
Supporting xReserve 充值 and 提现
The required dar file can be found here
There are 3 choices (API calls) a 钱包 will need to implement in order to fully support the xReserve:
Onboarding
To use the xReserve a party will first need to onboard to the bridge using the below: 示例 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 用户 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.充值:DepositAttestation
示例 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 用户 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 钱包 will need to provide:
- The available Holding 合约 Ids
- A UUID as the requestId
示例 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}",
}
Extracting 合约 IDs and Disclosed 合约
The utilities backend provides a Burn Mint Factory API 端点
端点:
${UTILITY_BACKEND_URL}/api/utilities/v0/registry/burn-mint-instruction/v0/burn-mint-factory
示例 请求 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 端点, the 响应 contains the 合约 IDs and disclosed 合约 you need for both minting and withdrawing.
请注意 these values can be cached to reduce api calls as these values change infrequently.
As an example for extracting the required contexts and 合约 from the 响应:
// 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;
MainNet Environment Variables
| Variable | Value |
|---|---|
| UTILITY_BACKEND_URL | https://api.utilities.digitalasset.com |
| ADMIN_PARTY_ID | decentralized-usdc-interchain-rep::12208115f1e168dd7e792320be9c4ca720c751a02a3053c7606e1c1cd3dad9bf60ef |
| UTILITY_OPERATOR_PARTY_ID | auth0_007c6643538f2eadd3e573dd05b9::12205bcc106efa0eaa7f18dc491e5c6f5fb9b0cc68dc110ae66f4ed6467475d7c78e |
| BRIDGE_OPERATOR_PARTY_ID | Bridge-Operator::1220c8448890a70e65f6906bd48d797ee6551f094e9e6a53e329fd5b2b549334f13f |
TestNet Environment Variables
| Variable | Value |
|---|---|
| UTILITY_BACKEND_URL | https://api.utilities.digitalasset-staging.com |
| ADMIN_PARTY_ID | decentralized-usdc-interchain-rep::122049e2af8a725bd19759320fc83c638e7718973eac189d8f201309c512d1ffec61 |
| UTILITY_OPERATOR_PARTY_ID | DigitalAsset-UtilityOperator::12202679f2bbe57d8cba9ef3cee847ac8239df0877105ab1f01a77d47477fdce1204 |
| BRIDGE_OPERATOR_PARTY_ID | Bridge-Operator::12209d011ce250de439fefc35d16d1ab9d56fb99ccb24c18d798efb22352d533bcdb |
本文由 CC Privacy Club 根据 Canton Network 官方文档(CC-BY-4.0)整理翻译,仅供学习;实现细节以官方最新版本为准。