Skip to content
Merged
93 changes: 85 additions & 8 deletions canton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,78 @@ This brings up:
- the **guardian** configured with `--cantonRPC canton:6865` (no
`--cantonReadAsParty`: the watcher uses a wildcard "any party" filter, §7.1).

### `guardianGovernance` external-party bootstrap

[`devnet/setup_guardian_governance.sh`](devnet/setup_guardian_governance.sh) +
[`devnet/guardian_governance.canton`](devnet/guardian_governance.canton) stand up
`guardianGovernance` as an **external, decentralized-namespace party** — a
namespace owned by three guardian keys with a 2-of-3 threshold, the same pattern
the Canton Network DSO party uses (§10, §11 item 2). Concretely, the console
script:

1. self-signs a root `NamespaceDelegation` for each of the three owner keys;
2. creates a `DecentralizedNamespaceDefinition` (2-of-3 threshold) over those
three owner namespaces, authorized by all three;
3. allocates `guardianGovernance::<namespace>` under it and hosts it at
**Confirmation** permission, with the same three keys registered as its
threshold transaction-signing keys (`PartyToParticipant.partySigningKeys` —
the standalone `PartyToKeyMapping` was deprecated in Canton 3.4 in favor of
this); and
4. writes an artifacts JSON (party id, decentralized-namespace id, owner
fingerprints/public-key paths, threshold) that is the handoff to submitting
the same topology to the CN global synchronizer later.

Every topology transaction is built **unsigned**
(`topology.transactions.generate`), signed by each owner's external signer, and
only then submitted (`topology.transactions.load`) — the participant never holds
an owner's private key, matching real guardian custody (HSM/KMS/offline signer).
`devnet/guardian_key_tool.js` is a small Ed25519 keypair/signing helper (Node's
built-in `crypto` module only, no dependencies) that stands in for that custody
system locally; production wires `GG_OWNER_SIGN_CMD_1/2/3` to the real one
instead (see the script's header for the full environment-variable contract).

[`devnet/genesis_guardian_governance.sh`](devnet/genesis_guardian_governance.sh)
exercises the resulting party: it prepares a `create CoreState` command
co-signed by an external `operator` party and `guardianGovernance` (the
interactive-submission equivalent of `Test.TestCore:setup`'s
`submit (actAs operator <> actAs guardianGovernance)`), has owners sign the
prepared
transaction hash, and executes. It supports three modes — `happy` (2-of-3,
succeeds), `threshold` (1-of-3, rejected), and `forge` (no guardianGovernance
signature at all, rejected — the anti-forgery property from §10: a compromised
operator cannot fabricate a `CoreState` bearing the real governance party).

[`devnet/test_guardian_governance.sh`](devnet/test_guardian_governance.sh) runs
all of the above against a fresh `dpm sandbox` and asserts the full table:
namespace/threshold/hosting shape, and all three genesis modes' expected
outcome.

```bash
canton/devnet/test_guardian_governance.sh
```

**Sandbox vs. CN fidelity.** The sandbox has no SVs or decentralized
synchronizer, so "registering with the super-validators" cannot be exercised
locally. That step is theoretical here by construction, not by omission: the
decentralized namespace is guardian-owned, so SVs never approve it — CN
"registration" is just (a) onboarding a participant to the CN global
synchronizer (one-time, SV-sponsored validator onboarding) and (b) submitting
the *same* topology transactions this script builds to that synchronizer instead
of the sandbox's, where they are sequenced and seen by all participants. The
script is synchronizer-target-parameterized (`GG_SYNCHRONIZER_ALIAS`,
`GG_CANTON_CONSOLE_CONFIG`) for exactly that reason, and the local run proves
the namespace/party/threshold mechanics port unchanged.

**Current limitation.** Party hosting is single-participant only
(`HostingParticipant(participant.id, Confirmation)` for the one console-bound
participant) — the sandbox has only one participant to host on, so
multi-participant Confirmation hosting (the production censorship-resistance
requirement) is not exercised locally. Extending the `Seq[HostingParticipant]`
built in `guardian_governance.canton` to additional participant ids is
mechanical; each additional participant's consent-to-host signature follows the
same `transactions.sign(..., signedBy = Seq(<that participant's fingerprint>))`
pattern already used for the first one.

### Status: opt-in while the k8s devnet path is validated

Unlike Sui, the `canton` component is **not** enabled by `--ci`. The guardian
Expand Down Expand Up @@ -896,14 +968,19 @@ human decision; see Open Questions.
— confirm and pin against Daml SDK 3.5.1 / Canton 3.5.x.
2. **`guardianGovernance` threshold party.** `CoreState` is co-signed by and keyed
on a `guardianGovernance` party (§4.1), so guardian-set integrity is anchored to
it rather than the operator. Stand it up as a decentralized-namespace external
party governed by a k-of-n threshold of guardian keys (DSO-style) and confirm:
the genesis co-sign ceremony, the topology-level rotation flow (add/remove
guardian keys without touching `CoreState`), and that consumers are configured
with the correct anchor party id. Note the `CoreState` singleton count ("exactly
one") is still behavioral — confirm the genesis automation creates exactly one.
(Emitter/manager **address** integrity is separately owner-bound and
owner-co-signed, §4.2.)
it rather than the operator. §9's `guardianGovernance` external-party bootstrap
stands it up as a decentralized-namespace external party governed by a 2-of-3
threshold of guardian keys (DSO-style), and its genesis co-sign ceremony is
confirmed locally (2-of-3 succeeds, 1-of-3 and no-signature are rejected).
Remaining before production: the CN-synchronizer submission itself (only
designed/parameterized here, not exercised — no CN localnet available), the
topology-level rotation flow (add/remove guardian keys without touching
`CoreState` — the decentralized namespace's own threshold governs this once
created, but rotation itself is untested), multi-participant Confirmation
hosting, and confirming consumers are configured with the correct anchor party
id. Note the `CoreState` singleton count ("exactly one") is still behavioral —
confirm the genesis automation creates exactly one. (Emitter/manager
**address** integrity is separately owner-bound and owner-co-signed, §4.2.)
3. **Guardian observer topology.** *Mechanism resolved:* the dedicated read-only
`guardianObserver` party is implemented as a template `observer` on
`Emitter`/`CoreState` (§4.1, §4.2, §10), and `--cantonReadAsParty` maps to it
Expand Down
211 changes: 211 additions & 0 deletions canton/devnet/genesis_guardian_governance.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
#
# Demonstrate that the guardianGovernance external party, bootstrapped by
# setup_guardian_governance.sh, can actually co-sign a transaction: prepare a
# `create CoreState` command (co-signed by an external `operator` party and
# guardianGovernance, mirroring Test.TestCore:setup's
# `submit (actAs operator <> actAs guardianGovernance)` but via interactive
# submission instead of Daml Script), have owners sign the prepared hash, and
# execute.
#
# This is a THIN orchestration layer over the JSON Ledger API's interactive
# submission endpoints (prepare/execute) -- see
# https://docs.digitalasset.com/build/3.5/tutorials/app-dev/external_signing_submission.html
# for the underlying flow. jq/curl/node are already relied on elsewhere in
# this repo (see scripts/delegated-guardian-set-preset.sh); no new
# dependencies.
#
# Usage:
# genesis_guardian_governance.sh <mode>
#
# mode: "happy" | "threshold" | "forge"
# happy Operator (1-of-1 external key) + 2-of-3 guardianGovernance
# signatures. Expected: contract created.
# threshold Operator + only 1-of-3 guardianGovernance signatures.
# Expected: rejected (threshold not met).
# forge Operator signs alone; guardianGovernance provides NO
# signature at all. Expected: rejected (the anti-forgery
# crux property -- the operator cannot fabricate the
# guardian-anchored CoreState).
#
# Required environment:
# GG_ARTIFACTS_FILE Artifacts JSON from setup_guardian_governance.sh.
# GG_OPERATOR_ARTIFACTS_FILE
# Same-shaped artifacts JSON for a single-owner
# (1-of-1) external `operator` party (this script
# does not allocate one; see
# test_guardian_governance.sh for how to produce it
# by re-running guardian_governance.canton with
# GG_PARTY_NAME=Operator, GG_THRESHOLD=1 and a
# single owner key repeated, or a dedicated
# single-key setup).
# GG_CORE_PACKAGE_ID Package id of the uploaded wormhole-core DAR
# (see `dpm inspect-dar`).
# CANTON_HOST / CANTON_LEDGER_API_PORT / CANTON_JSON_API_PORT
# Default localhost / 6865 / 6864 (sandbox
# defaults).
set -euo pipefail

MODE="${1:?usage: genesis_guardian_governance.sh <happy|threshold|forge>}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

HOST="${CANTON_HOST:-localhost}"
JSON_PORT="${CANTON_JSON_API_PORT:-6864}"
BASE_URL="http://${HOST}:${JSON_PORT}"

: "${GG_ARTIFACTS_FILE:?GG_ARTIFACTS_FILE is required}"
: "${GG_OPERATOR_ARTIFACTS_FILE:?GG_OPERATOR_ARTIFACTS_FILE is required}"
: "${GG_CORE_PACKAGE_ID:?GG_CORE_PACKAGE_ID is required}"

GG_PARTY="$(jq -r '.partyId' "${GG_ARTIFACTS_FILE}")"
GG_THRESHOLD_VALUE="$(jq -r '.threshold' "${GG_ARTIFACTS_FILE}")"
SYNCHRONIZER_ID="$(jq -r '.synchronizerId' "${GG_ARTIFACTS_FILE}")"
# The JSON Ledger API's `synchronizerId` field on PrepareSubmissionRequest
# wants the LOGICAL synchronizer id ("<name>::<namespace-fingerprint>"), not
# the PHYSICAL id this repo's artifacts carry ("...::<protocol>-<serial>") --
# strip the trailing "::<protocolVersion>-<serial>" suffix.
LOGICAL_SYNCHRONIZER_ID="$(echo "${SYNCHRONIZER_ID}" | sed -E 's/(::[^:]+)::[0-9]+-[0-9]+$/\1/')"

OP_PARTY="$(jq -r '.partyId' "${GG_OPERATOR_ARTIFACTS_FILE}")"
OP_FINGERPRINT="$(jq -r '.owners[0].namespace' "${GG_OPERATOR_ARTIFACTS_FILE}")"
OP_KEYFILE_PUB="$(jq -r '.owners[0].publicKeyFile' "${GG_OPERATOR_ARTIFACTS_FILE}")"
OP_KEYFILE="${OP_KEYFILE_PUB%.pub}.key"

sign_hash() {
local keyfile="$1" hash_hex="$2"
node "${SCRIPT_DIR}/guardian_key_tool.js" sign "${keyfile}" "${hash_hex}"
}

hex_to_b64() {
python3 -c 'import sys, base64; sys.stdout.write(base64.b64encode(bytes.fromhex(sys.argv[1])).decode())' "$1"
}

b64_to_hex() {
python3 -c 'import sys, base64; sys.stdout.write(base64.b64decode(sys.argv[1]).hex())' "$1"
}

sig_json() {
local sig_hex="$1" fingerprint="$2"
local sig_b64
sig_b64="$(hex_to_b64 "${sig_hex}")"
jq -n --arg sig "${sig_b64}" --arg fp "${fingerprint}" \
'{format: "SIGNATURE_FORMAT_CONCAT", signature: $sig, signedBy: $fp, signingAlgorithmSpec: "SIGNING_ALGORITHM_SPEC_ED25519"}'
}

# --- Build the create-CoreState command, matching mkInitialCoreState's devnet
# values (Test.TestCore.setup / TestCore.daml). ---
COMMAND_ID="guardian-governance-genesis-${MODE}-$$"
CREATE_ARGS="$(jq -n \
--arg operator "${OP_PARTY}" \
--arg gg "${GG_PARTY}" \
'{
operator: $operator,
guardianGovernance: $gg,
chainId: "72",
governanceChainId: "1",
governanceContract: "0000000000000000000000000000000000000000000000000000000000000004",
guardianSetIndex: "0",
guardianSets: [["0", {"keys": ["befa429d57cd18b7f8a4d91a2da9ab4af05d0fbe"]}]],
messageFee: "0",
consumedGovernance: {"map": []}
}')"

PREPARE_REQUEST="$(jq -n \
--arg cmdId "${COMMAND_ID}" \
--arg op "${OP_PARTY}" \
--arg gg "${GG_PARTY}" \
--arg synchronizerId "${LOGICAL_SYNCHRONIZER_ID}" \
--arg templateId "${GG_CORE_PACKAGE_ID}:Wormhole.Core.State:CoreState" \
--argjson createArgs "${CREATE_ARGS}" \
'{
commandId: $cmdId,
actAs: [$op, $gg],
synchronizerId: $synchronizerId,
packageIdSelectionPreference: [],
verboseHashing: false,
hashingSchemeVersion: "HASHING_SCHEME_VERSION_V3",
userId: "participant_admin",
commands: [ { CreateCommand: { templateId: $templateId, createArguments: $createArgs } } ]
}')"

echo "[genesis:${MODE}] preparing CoreState create command..." >&2
PREPARE_RESPONSE="$(curl -sf -X POST "${BASE_URL}/v2/interactive-submission/prepare" \
-H 'Content-Type: application/json' -d "${PREPARE_REQUEST}")"
PREPARED_TX="$(echo "${PREPARE_RESPONSE}" | jq -r '.preparedTransaction')"
HASH_B64="$(echo "${PREPARE_RESPONSE}" | jq -r '.preparedTransactionHash')"
HSV="$(echo "${PREPARE_RESPONSE}" | jq -r '.hashingSchemeVersion')"
HASH_HEX="$(b64_to_hex "${HASH_B64}")"

OP_SIG_HEX="$(sign_hash "${OP_KEYFILE}" "${HASH_HEX}")"
OP_SIG_JSON="$(sig_json "${OP_SIG_HEX}" "${OP_FINGERPRINT}")"

GG_SIGNATURES="[]"
case "${MODE}" in
happy)
# Sign with the first GG_THRESHOLD_VALUE owners -- exactly meets threshold.
for i in $(seq 0 $((GG_THRESHOLD_VALUE - 1))); do
fp="$(jq -r ".owners[$i].namespace" "${GG_ARTIFACTS_FILE}")"
pubfile="$(jq -r ".owners[$i].publicKeyFile" "${GG_ARTIFACTS_FILE}")"
keyfile="${pubfile%.pub}.key"
sig_hex="$(sign_hash "${keyfile}" "${HASH_HEX}")"
GG_SIGNATURES="$(echo "${GG_SIGNATURES}" | jq --argjson s "$(sig_json "${sig_hex}" "${fp}")" '. + [$s]')"
done
;;
threshold)
# One signature only -- below threshold, must be rejected.
fp="$(jq -r '.owners[0].namespace' "${GG_ARTIFACTS_FILE}")"
pubfile="$(jq -r '.owners[0].publicKeyFile' "${GG_ARTIFACTS_FILE}")"
keyfile="${pubfile%.pub}.key"
sig_hex="$(sign_hash "${keyfile}" "${HASH_HEX}")"
GG_SIGNATURES="[$(sig_json "${sig_hex}" "${fp}")]"
;;
forge)
# No guardianGovernance signature at all -- operator alone must not be
# able to forge the CoreState.
GG_SIGNATURES="[]"
;;
*)
echo "unknown mode: ${MODE}" >&2
exit 2
;;
esac

PARTY_SIGNATURES="$(jq -n \
--arg op "${OP_PARTY}" --argjson opSig "[${OP_SIG_JSON}]" \
--arg gg "${GG_PARTY}" --argjson ggSig "${GG_SIGNATURES}" \
'if ($ggSig | length) > 0 then
{ signatures: [ {party: $op, signatures: $opSig}, {party: $gg, signatures: $ggSig} ] }
else
{ signatures: [ {party: $op, signatures: $opSig} ] }
end')"

EXECUTE_REQUEST="$(jq -n \
--arg prepared "${PREPARED_TX}" \
--argjson partySignatures "${PARTY_SIGNATURES}" \
--arg submissionId "${COMMAND_ID}-exec" \
--arg hsv "${HSV}" \
'{
preparedTransaction: $prepared,
partySignatures: $partySignatures,
submissionId: $submissionId,
userId: "participant_admin",
hashingSchemeVersion: $hsv,
deduplicationPeriod: { Empty: {} }
}')"

OWNER_COUNT="$(jq '.owners | length' "${GG_ARTIFACTS_FILE}")"
echo "[genesis:${MODE}] executing with $(echo "${GG_SIGNATURES}" | jq 'length')-of-${OWNER_COUNT} guardianGovernance signature(s) (threshold ${GG_THRESHOLD_VALUE})..." >&2
EXEC_RESPONSE_FILE="$(mktemp)"
trap 'rm -f "${EXEC_RESPONSE_FILE}"' EXIT
HTTP_CODE="$(curl -s -o "${EXEC_RESPONSE_FILE}" -w '%{http_code}' -X POST \
"${BASE_URL}/v2/interactive-submission/execute" -H 'Content-Type: application/json' -d "${EXECUTE_REQUEST}")"

echo "[genesis:${MODE}] HTTP ${HTTP_CODE}" >&2
cat "${EXEC_RESPONSE_FILE}"
echo

if [ "${HTTP_CODE}" = "200" ]; then
exit 0
else
exit 1
fi
Loading
Loading