-
Notifications
You must be signed in to change notification settings - Fork 0
External signing
For the full utility list, see Reference and filter for the group you care about (Amulet, external signing, etc.).
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' } },
});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
The onboarding process involves three steps:
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!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 submitSign 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',
}],
});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, ... }External parties use a prepare-sign-execute flow:
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 usedSign the transaction hash with your private key:
const hashBuffer = Buffer.from(prepared.preparedTransactionHash, 'base64');
const signature = keypair.sign(hashBuffer);
const signatureBase64 = signature.toString('base64');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' }
},
});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,
});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');// 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 productionOnboards an external party in a single function call.
Parameters:
-
ledgerClient: LedgerJsonApiClient instance -
keypair: Stellar Keypair object -
partyName: Hint for party ID -
synchronizerId: Synchronizer to onboard on
Returns: Party details with partyId, publicKeyFingerprint, etc.
Prepares a transaction for external signing.
Parameters:
-
ledgerClient: LedgerJsonApiClient instance -
commands: Array of commands to execute -
actAs: Array of party IDs acting -
commandId: Unique command identifier -
synchronizerId: Synchronizer ID -
readAs(optional): Parties with read-only access -
disclosedContracts(optional): Contracts to disclose
Returns: Prepared transaction and hash to sign
Executes a signed transaction.
Parameters:
-
ledgerClient: LedgerJsonApiClient instance -
preparedTransaction: From prepare step -
partyId: Signing party ID -
signature: Base64-encoded signature -
publicKeyFingerprint: Fingerprint of signing key -
submissionId: Unique submission identifier -
deduplicationPeriod: Deduplication configuration
Returns: Execution result (empty on success)
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.
Canton uses a specific hashing algorithm for transaction signatures. The current version is documented in the External Signing Hashing Algorithm guide.
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.
"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
- 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