Canton Node SDK
External signing
Create and manage external parties and submit externally signed transactions on Canton.
For the full utility list, see Reference and filter for the group you care about (Amulet, external signing, etc.).
Quick Start
import { Keypair } from '@stellar/stellar-base';
import { CantonRuntime, LedgerJsonApiClient, createExternalParty, prepareExternalTransaction, executeExternalTransaction } from '@fairmint/canton-node-sdk';
// 1. Create external party with a local keypair
const keypair = Keypair.random();
const runtime = new CantonRuntime({ network: 'localnet' });
const ledgerClient = new LedgerJsonApiClient(runtime);
const party = await createExternalParty({
ledgerClient,
keypair,
partyName: 'alice',
synchronizerId: 'global-synchronizer',
});
// 2. Prepare a transaction
const prepared = await prepareExternalTransaction({
ledgerClient,
userId: party.userId, // Required for authorization
commands: [/* your commands */],
actAs: [party.partyId],
commandId: `cmd-${Date.now()}`,
synchronizerId: 'global-synchronizer',
});
// 3. Sign the transaction hash
const hashBuffer = Buffer.from(prepared.preparedTransactionHash, 'base64');
const signature = keypair.sign(hashBuffer);
// 4. Execute the signed transaction
const result = await executeExternalTransaction({
ledgerClient,
preparedTransaction: prepared.preparedTransaction,
partyId: party.partyId,
signature: signature.toString('base64'),
publicKeyFingerprint: party.publicKeyFingerprint,
submissionId: `sub-${Date.now()}`,
deduplicationPeriod: { DeduplicationDuration: { duration: '30s' } },
});
What are External Parties?
In Canton, there are two types of parties:
- Internal Parties: The participant node holds the signing key and signs transactions on behalf of the party
- External Parties: The user holds the signing key externally (e.g., in a wallet or HSM)
External parties provide:
- ✅ Enhanced security: Private keys never leave the user’s control
- ✅ Wallet integration: Works with hardware wallets, browser extensions, mobile wallets
- ✅ User sovereignty: Users maintain complete control over their digital identity
- ✅ Multi-signature support: Parties can require multiple signatures from different keys
Onboarding an External Party
The onboarding process involves three steps:
1. Generate Key Pair
import { Keypair } from '@stellar/stellar-base';
// Generate a new Stellar Ed25519 keypair
const keypair = Keypair.random();
console.log('Public Key:', keypair.publicKey());
console.log('Secret Key:', keypair.secret()); // Store securely!
2. Generate Topology
Request Canton to generate the topology transactions for the external party:
const topology = await ledgerClient.generateExternalPartyTopology({
synchronizer: 'global-synchronizer',
partyHint: 'alice',
publicKey: {
format: 'CRYPTO_KEY_FORMAT_RAW',
keyData: keypair.rawPublicKey().toString('base64'),
keySpec: 'SIGNING_KEY_SPEC_EC_CURVE25519',
},
});
// Returns:
// - partyId: The generated party ID (e.g., "alice::12abc...")
// - multiHash: Hash that needs to be signed
// - publicKeyFingerprint: Fingerprint of the public key
// - topologyTransactions: Transactions to submit
3. Sign and Allocate
Sign the multi-hash and submit to allocate the party:
const hashBuffer = Buffer.from(topology.multiHash, 'base64');
const signature = keypair.sign(hashBuffer);
const result = await ledgerClient.allocateExternalParty({
synchronizer: 'global-synchronizer',
identityProviderId: 'default',
onboardingTransactions: topology.topologyTransactions?.map(tx => ({ transaction: tx })) ?? [],
multiHashSignatures: [{
format: 'SIGNATURE_FORMAT_RAW',
signature: signature.toString('base64'),
signedBy: topology.publicKeyFingerprint,
signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519',
}],
});
4. Create User and Grant Rights
After allocating the party, create a user and grant permissions:
const userId = `user-${result.partyId}`;
await ledgerClient.interactiveSubmissionCreateUser({
user: {
id: userId,
primaryParty: result.partyId,
isDeactivated: false,
identityProviderId: 'default',
},
rights: [
{
kind: {
CanActAs: { party: result.partyId },
},
},
],
});
Or use the convenience function (handles all steps automatically):
import { createExternalParty } from '@fairmint/canton-node-sdk';
const party = await createExternalParty({
ledgerClient,
keypair,
partyName: 'alice',
synchronizerId: 'global-synchronizer',
});
// Returns: { partyId, userId, publicKeyFingerprint, ... }
Submitting Externally Signed Transactions
External parties use a prepare-sign-execute flow:
1. Prepare Transaction
The participant interprets your commands and returns a prepared transaction:
import { prepareExternalTransaction } from '@fairmint/canton-node-sdk';
const prepared = await prepareExternalTransaction({
ledgerClient,
userId: party.userId, // User with CanActAs rights
commands: [{
CreateCommand: {
templateId: 'MyPackage:MyModule:MyTemplate',
createArgument: { field1: 'value1' },
}
}],
actAs: ['alice::12abc...'],
commandId: `create-contract-${Date.now()}`,
synchronizerId: 'global-synchronizer',
});
// Returns:
// - preparedTransaction: The interpreted transaction
// - preparedTransactionHash: Hash to sign
// - hashingSchemeVersion: Version of hashing algorithm used
2. Sign Transaction Hash
Sign the transaction hash with your private key:
const hashBuffer = Buffer.from(prepared.preparedTransactionHash, 'base64');
const signature = keypair.sign(hashBuffer);
const signatureBase64 = signature.toString('base64');
⚠️ Important: The signature is over the entire transaction output, not just the command. If the participant is malicious or faulty, validators will reject the transaction.
3. Execute Signed Transaction
Submit the prepared transaction with your signature:
import { executeExternalTransaction } from '@fairmint/canton-node-sdk';
const result = await executeExternalTransaction({
ledgerClient,
preparedTransaction: prepared.preparedTransaction,
partyId: 'alice::12abc...',
signature: signatureBase64,
publicKeyFingerprint: party.publicKeyFingerprint,
submissionId: `sub-${Date.now()}`,
deduplicationPeriod: {
DeduplicationDuration: { duration: '30s' }
},
});
Multi-Hosted Parties
External parties can be hosted by multiple participants for enhanced security and availability:
const topology = await ledgerClient.generateExternalPartyTopology({
synchronizer: 'global-synchronizer',
partyHint: 'alice',
publicKey: { /* ... */ },
// Party hosted by multiple participants
otherConfirmingParticipantUids: [
'participant2::fingerprint',
'participant3::fingerprint',
],
// Require at least 2 of 3 participants to confirm
confirmationThreshold: 2,
});
Key Management
Stellar Keypairs
This SDK uses Stellar’s Ed25519 keypairs, which are compatible with Canton’s signing requirements:
import { Keypair } from '@stellar/stellar-base';
// Generate new keypair
const keypair = Keypair.random();
// Load from secret
const keypair = Keypair.fromSecret('SABCD...');
// Convert to Canton format
const publicKeyBase64 = keypair.rawPublicKey().toString('base64');
Storing Keys Securely
// Save to encrypted file
const keyData = {
partyId: result.partyId,
stellarAddress: keypair.publicKey(),
stellarSecret: keypair.secret(), // Encrypt this!
publicKeyFingerprint: result.publicKeyFingerprint,
};
// ⚠️ Never commit private keys to git
// ⚠️ Encrypt before storing
// ⚠️ Use hardware wallets for production
API Reference
createExternalParty(params)
Onboards an external party in a single function call.
Parameters:
ledgerClient: LedgerJsonApiClient instancekeypair: Stellar Keypair objectpartyName: Hint for party IDsynchronizerId: Synchronizer to onboard on
Returns: Party details with partyId, publicKeyFingerprint, etc.
prepareExternalTransaction(params)
Prepares a transaction for external signing.
Parameters:
ledgerClient: LedgerJsonApiClient instancecommands: Array of commands to executeactAs: Array of party IDs actingcommandId: Unique command identifiersynchronizerId: Synchronizer IDreadAs(optional): Parties with read-only accessdisclosedContracts(optional): Contracts to disclose
Returns: Prepared transaction and hash to sign
executeExternalTransaction(params)
Executes a signed transaction.
Parameters:
ledgerClient: LedgerJsonApiClient instancepreparedTransaction: From prepare steppartyId: Signing party IDsignature: Base64-encoded signaturepublicKeyFingerprint: Fingerprint of signing keysubmissionId: Unique submission identifierdeduplicationPeriod: Deduplication configuration
Returns: Execution result (empty on success)
Architecture
Trust Model
External parties allow you to minimize trust in the participant node:
-
Onboarding: You only trust the participant to construct valid topology transactions. Validators verify all signatures before accepting.
-
Transaction Submission: You sign the entire transaction output, not just your command. If the participant interprets incorrectly, validators reject the transaction.
-
Multi-Hosting: Host your party on multiple participants with a threshold requirement to eliminate single point of trust.
Hashing Algorithm
Canton uses a specific hashing algorithm for transaction signatures. The current version is documented in the External Signing Hashing Algorithm guide.
Known Limitations
Permission Requirements for Transaction Preparation
Canton requires that the user preparing transactions must have CanReadAs or CanActAs rights for all parties involved, even when using external signing. This presents a challenge:
- After onboarding an external party, a Canton administrator must grant
CanReadAsrights to the user that will prepare transactions - The M2M validator operator user typically doesn’t have admin permissions to grant these rights programmatically
- This requirement exists because Canton needs to validate that the transaction is well-formed before generating the hash for signing
Current Status:
- ✅ External party onboarding works
- ✅ Transfer offer creation works
- ❌ Transaction preparation blocked by permissions
Workaround Options:
Option 1: Manual Rights Grant (Recommended)
Have a Canton administrator manually grant rights after external party onboarding:
// Using Canton Ledger API with admin credentials
await ledgerClient.grantUserRights({
userId: '5', // Or your validator operator user
rights: [{
kind: {
CanReadAs: {
value: {
party: 'external-party-id::hash...'
}
}
}
}]
});
Option 2: Use Admin Credentials
Configure the SDK to use a Canton admin user with ParticipantAdmin or IdentityProviderAdmin rights for SDK operations.
Troubleshooting
Common Issues
“Security-sensitive error” or HTTP 403 when preparing transactions
- Cause: The user doesn’t have
CanReadAsrights for the external party - Solution: Have a Canton admin grant the necessary rights (see Known Limitations above)
- Verification: Check user rights with
ledgerClient.listUserRights({ userId: '5' })
“Party not found” errors
- Ensure the party is properly onboarded before attempting transactions
- Verify the party ID matches exactly (including the fingerprint)
“Unknown or not connected synchronizer” errors
- The synchronizer ID has changed or is incorrect
- Use
validatorClient.getOpenAndIssuingMiningRounds()to get the current synchronizer ID
Signature verification failures
- Ensure you’re using the same keypair that was used during onboarding
- Verify the public key fingerprint matches
- Check that the hash being signed is the
preparedTransactionHashfrom the prepare response
Further Reading
- External Signing Overview - Comprehensive overview of external signing
- Onboard External Party (Ledger API) - Step-by-step onboarding tutorial
- Submit Externally Signed Transactions - Transaction submission guide
- Topology Management - Understanding Canton topology system