- Decentralization Manager
- Operations and reference
- Decentralize an external party
Decentralize an external party
This guide hosts an external party on several participants. An external party signs its own transactions with a key that its owner holds. Its namespace is the fingerprint of that key.
The guide covers two jobs:
- Create a new party that several participants host from the start.
- Add hosts to an existing party, and copy its contracts to them.
Both jobs use the tenant API, /v0/tenant/*, on the node next to each host.
- Host: a participant that hosts the party, with a Decentralization Manager node next to it.
- Joining host: a host that the party gains in this procedure.
- Wallet: the program that holds the party’s key and calls every host. The private key never leaves the wallet. Decentralization Manager receives only the public key and signatures.
A Rust wallet can use the decman-wallet library, which runs these steps. This guide shows the same steps with curl.
For a party whose namespace is a participant’s key, use Decentralize an existing party. For a Decentralized Party, use Add a member to a Decentralized Party.
What more hosts give the party
Section titled “What more hosts give the party”An external party signs its own transactions, so every host has Confirmation permission. Two things follow:
- Failover. The wallet can submit through any host. The party keeps working while one host is down.
- Co-validation. The threshold is how many hosts must confirm each transaction. At threshold 1, any one host is enough. Above 1, no single host can act for the party alone.
Always send "permission": "confirmation", or leave the field out. A Submission host submits for a party that has no key of its own. That option is the failover outcome for a local party. See Decentralize an existing party.
Set up each host
Section titled “Set up each host”The operator of each host does this once.
- Turn on the tenant API, and test a key. The Tenant API keys section of Authentication and operator roles has both steps. With no keys, the node refuses every tenant call.
- Let the wallet reach the node. Route the node’s HTTP port to the wallet over TLS. The tenant API checks the tenant key, not the operator login.
- Vet the party’s DARs. A joining host needs every package that the party’s contracts use. See Check the DARs.
- Send the wallet three things: the node URL, a tenant key, and the participant ID. The Configuration page shows the Participant ID.
Each host accepts only its own keys. So the wallet keeps one key for each host, and sends each host that host’s key.
Sign a hash
Section titled “Sign a hash”Every write takes two calls. The prepare call returns transaction_hashes. The wallet signs each hash with the party’s key. The execute call then sends the signatures with the prepare output.
These rules apply to every signature:
- Decode each hash from base64, and sign the bytes as they are. Do not hash them again.
- Send each signature base64-encoded, in the same order as the hashes.
- Set
signed_byto the key’s fingerprint, which is the namespace half of the party ID.
The fingerprint is the SHA-256 hash of two inputs: the 4-byte big-endian number 12, then the raw 32-byte Ed25519 public key. Write it in hex, after the prefix 1220.
This Python helper makes a key, prints its public half, and signs a prepare answer. It needs the cryptography package.
# key.py: new KEY_FILE | pub KEY_FILE | sign KEY_FILE PREPARE_JSONimport base64, hashlib, json, os, struct, sysfrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKeyfrom cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
mode, path = sys.argv[1], sys.argv[2]if mode == "new": seed = Ed25519PrivateKey.generate().private_bytes_raw() fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) os.write(fd, base64.b64encode(seed)) sys.exit()key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(open(path).read()))pub = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)fingerprint = "1220" + hashlib.sha256(struct.pack(">i", 12) + pub).hexdigest()if mode == "pub": print(json.dumps({"public_key": base64.b64encode(pub).decode(), "fingerprint": fingerprint}))else: prepared = json.load(open(sys.argv[3])) signatures = [base64.b64encode(key.sign(base64.b64decode(h))).decode() for h in prepared["transaction_hashes"]] print(json.dumps({"signatures": signatures, "signed_by": fingerprint}))An existing party can also have an ECDSA key on secp256k1, P-256, or P-384. The host reads the key type from the party’s mapping. Sign each hash with ECDSA over SHA-256, or over SHA-384 for P-384. Send a DER signature, or the fixed-width r || s pair. That pair is 64 bytes, or 96 bytes for P-384. A new party takes an Ed25519 key only.
Compare before you sign
Section titled “Compare before you sign”The wallet calls prepare on every host, and requires the same bytes from each one. The wallet cannot read a Canton hash. Without this check, one host could return the hash of a different mapping, for example one that adds its own key. With the check, one honest host is enough to expose a dishonest one.
# Every line must show the same hash.for f in prepare-*.json; do jq -S . "$f" | sha256sum; doneStop if two hashes differ.
Create a new party
Section titled “Create a new party”The party gets two or more hosts, and starts with no contracts.
-
Make a key and read its public half:
Terminal window python3 key.py new party.keyPUB=$(python3 key.py pub party.key | jq -r .public_key) -
Call
prepareon every host. Each host lists the other hosts inhosting_peers:Terminal window # hosts.txt holds one "NODE_URL PARTICIPANT_ID TENANT_KEY" line per host.# It holds the keys, so keep it private: chmod 600 hosts.txtHINT=alicewhile read -r url pid key; dopeers=$(awk -v me="$pid" '$2 != me {print $2}' hosts.txt | jq -R . | jq -s .)curl -sS -X POST "$url/v0/tenant/prepare" \-H "Authorization: Bearer $key" -H 'Content-Type: application/json' \-d "$(jq -n --arg h "$HINT" --arg k "$PUB" --argjson p "$peers" \'{party_hint:$h, public_key:$k, hosting_peers:$p}')" \> "prepare-${pid%%::*}.json"done < hosts.txtconfirmation_thresholdis optional. It defaults to the number of hosts minus 1, and it cannot be higher. A host can then always leave later. -
Compare the answers, then sign one of them:
Terminal window python3 key.py sign party.key prepare-<any host>.json > signed.json -
Call
executeon every host with the same body:Terminal window jq -n --arg h "$HINT" --arg k "$PUB" \--slurpfile p prepare-<any host>.json --slurpfile s signed.json \'{party_hint:$h, public_key:$k,topology_transactions:$p[0].topology_transactions,signatures:$s[0].signatures, signed_by:$s[0].signed_by}' > execute.jsonwhile read -r url pid key; docurl -sS -X POST "$url/v0/tenant/execute" \-H "Authorization: Bearer $key" -H 'Content-Type: application/json' \-d @execute.jsondone < hosts.txtEach call answers HTTP
202. Canton activates the party after the last host has signed, so the order does not matter. -
Poll
GET /v0/tenant/{party}/statuson every host until each one answers{"status": "completed"}.
The party ID is alice::<fingerprint>. Each host also lists the party on its Parties → External Parties tab, within a few minutes.
The External Parties list
Section titled “The External Parties list”The tab and GET /external-parties read a cached list. A request for a list older than five minutes starts a new scan in the background. DECPM_EXTERNAL_PARTIES_TTL_SECS changes that age. A scan reads the whole topology store, so keep the age in minutes.
In the answer, fetched_at says when the last scan finished, and refreshing says whether a scan runs now. To read a fresh list, repeat the request until refreshing is false. A failed scan keeps the last good list, and the answer adds refresh_error.
After a restart, the first scan can take minutes. Until it ends, fetched_at is null and parties is empty. An empty list then does not mean that the host has no external parties.
Add hosts to an existing party
Section titled “Add hosts to an existing party”Check the DARs
Section titled “Check the DARs”The joining hosts need every package that the party’s contracts use. Decentralization Manager cannot check this for an external party. The check reads the contracts over the Ledger API, and the hosts hold no ledger credential for the party.
Without the packages, the import fails after the joining host has left the synchronizer. So compare the vetted packages on the Packages page of each joining host with a current host, and distribute what is missing. See Package and contract operations.
-
Read the serial from one current host. This guide calls that host the source:
$SOURCE, with the key$SOURCE_KEY. Every write pins the serial that it builds on:Terminal window curl -sS "$SOURCE/v0/tenant/$PARTY/state" -H "Authorization: Bearer $SOURCE_KEY"# {"party_id": "...", "serial": 1, "threshold": 1, "host_count": 2,# "onboarding_hosts": 0, "has_signing_key": true} -
Prepare on every host, current and joining, with the same body:
Terminal window # all-hosts.txt: every current and joining host, in the hosts.txt format.# NEW_HOSTS: a JSON array of the joining hosts' participant IDs.jq -n --arg p "$PARTY" --argjson s "$SERIAL" --argjson n "$NEW_HOSTS" \'{party_id:$p, permission:"confirmation", new_hosts:$n, base_serial:$s}' > add.jsonwhile read -r url pid key; docurl -sS -X POST "$url/v0/tenant/add-hosts/prepare" \-H "Authorization: Bearer $key" -H 'Content-Type: application/json' \-d @add.json > "prepare-${pid%%::*}.json"done < all-hosts.txtCompare the answers, then sign one of them with
key.py sign. -
Execute on each joining host only. Canton needs the party’s signature and each joining host’s signature. The current hosts add nothing. Send the same body to every joining host, with that host’s own key:
Terminal window jq -n --arg p "$PARTY" --argjson s "$SERIAL" \--slurpfile x prepare-<any host>.json --slurpfile g signed.json \'{party_id:$p, base_serial:$s, topology_transactions:$x[0].topology_transactions,signatures:$g[0].signatures, signed_by:$g[0].signed_by}' > execute.jsoncurl -sS -X POST "$JOINER/v0/tenant/add-hosts/execute" \-H "Authorization: Bearer $JOINER_KEY" -H 'Content-Type: application/json' \-d @execute.json -
Wait for the change. Poll
/stateon the source untilhost_countincludes the joining hosts.onboarding_hoststhen counts them too. A joining host now hosts the party, but it does not confirm yet. -
Copy the contracts to each joining host. Read block N from the source and hand it to the joining host, until the import answers
"complete": true:Terminal window seq=1while :; docurl -sS -H "Authorization: Bearer $SOURCE_KEY" \"$SOURCE/v0/tenant/$PARTY/acs/$JOINER_ID?base_serial=$SERIAL&seq=$seq" > block.jsonjq --arg p "$PARTY" --argjson s "$SERIAL" \'{party_id:$p, base_serial:$s, seq, chunk, end, total_len, sha256, package_ids}' \block.json > import.jsoncurl -sS -X POST "$JOINER/v0/tenant/add-hosts/import" \-H "Authorization: Bearer $JOINER_KEY" -H 'Content-Type: application/json' \-d @import.json > result.jsonjq -e .complete result.json > /dev/null && breakseq=$((seq + 1))donecat result.json # {"complete": true, "imported": true, "marker_cleared": true, ...}$SERIALis the serial from step 1, not the new one. Use the same source host for every block of one copy. -
Wait for the onboarding markers to clear. Poll
/stateuntilonboarding_hostsis 0. The party can transact again from here.
How the copy works
Section titled “How the copy works”The joining host leaves the synchronizer when block 1 arrives, and it reconnects after the last block. For that time, every party on the joining host pauses. The current hosts keep running.
- Neither node writes the contracts to disk, or keeps more than one block in memory. Each import call returns after Canton has taken the block, and this sets the pace.
- A copy cannot resume. If it breaks, ask for block 1 again. The source then opens a fresh export, and the joining host starts a fresh import.
- After a network error, you can ask for the last block again. The source serves the last block again, so one retry is safe.
- A copy that stays idle for 5 minutes ends. The joining host reconnects to the synchronizer, and you start again from block 1.
Raise the threshold
Section titled “Raise the threshold”This step is for co-validation only, and only after every marker has cleared. A host with a marker cannot confirm, so it cannot count toward the threshold.
- Read the serial again with
/state. The add and each marker clear moved it. - Call
/v0/tenant/threshold/prepareon every host with{party_id, new_threshold, base_serial}. Compare the answers, then sign. - Call
/v0/tenant/threshold/executeon every host with{party_id, base_serial, topology_transactions, signatures, signed_by}. - Poll
/stateuntilthresholdshows the new value.
The new threshold must be between 1 and the number of hosts that can confirm now. Above threshold 1, the party needs that many hosts to confirm each transaction. No single host can then act for it.
Transact as the party
Section titled “Transact as the party”Decentralization Manager does not submit transactions for an external party. The wallet uses interactive submission on the Ledger API of any host: PrepareSubmission, then a signature over the prepared hash, then ExecuteSubmission.
The wallet needs a Ledger API user on that participant that can read as the party and execute submissions for it. The host operator grants those rights.
If something goes wrong
Section titled “If something goes wrong”| Symptom | Cause | Do this |
|---|---|---|
401 |
The key is not one of that host’s keys, or the host has no keys set | Send that host its own key. Check DECPM_TENANT_API_KEYS on the host |
409 from a prepare or execute call |
The party’s serial changed after you read it | Read /state again and restart from prepare |
404 from a prepare or execute call |
No hosting mapping exists for the party | Check the party ID. A joining host still answers 200, because it reads the shared topology |
400 that names a field |
The bundle does not match what the host built itself | Do not retry it as it is. Restart from prepare |
| The prepare answers differ | A host built a different mapping | Stop, and ask that host’s operator |
409 from the ACS or import call |
The two ends disagree about the next block | Restart the copy from block 1 |
| The import fails, and the joining host is back online | A block failed, or the copy was idle too long | Restart the copy from block 1 |
500 “Imported the ACS, but could not request the marker clear” on the last block |
Canton already has its own marker clear pending | Nothing. Poll /state until onboarding_hosts is 0 |
"marker_cleared": false |
Canton clears the marker a short time after the import | Poll /state until onboarding_hosts is 0 |
"package_preflight": false in a block |
The source cannot list the party’s packages | Normal for an external party. Check the DARs yourself before the copy |
| The joining host restarts in a loop after an import | It holds archives of contracts that it never had | This needs a manual repair. Contact the BitSafe team |
The host log can show Could not record ACS transfer progress: FOREIGN KEY constraint failed during an import. This message affects progress reporting only.
Commitment mismatches after an import
Section titled “Commitment mismatches after an import”A joining host can report commitment mismatches after it starts to confirm. Its copy of the contracts then differs from what the other hosts expect. Canton’s RepairCommitmentsUsingAcs recomputes the commitments from the imported contracts. Run it on the joining host only, and only after the import has completed.
Limits
Section titled “Limits”- No resume. A broken copy restarts from block 1.
- No transfer without the wallet. The wallet carries every block. The hosts never talk to each other.
- Very large parties. The import validates every contract again while the joining host is offline. For a very large party, that time is the limit, not the transfer.
- No new party with contracts. Create a new party makes an empty party. To decentralize a party that holds contracts, add hosts to it.
Technical sources
Section titled “Technical sources”- Decentralizing an external party guide
- Tenant API handlers
- Tenant API request types
- External-party onboarding steps
- Add-hosts topology
- Wallet-relayed contract copy
- External Parties list cache
- Fingerprint derivation
- decman-wallet library
Related pages
Section titled “Related pages”Ask the docs
Answers come only from this documentation and can be wrong. Check the linked pages.
Ask a question about BitSafe, CBTC or the developer guides. For example: