This guide describes how to use the Multi-party Compute API to manage distributed keys, participants, and policies. It is intended for preview customers learning the key user journeys.
Overview
The MPC API consists of two main components:
- Core MPC manages the lifecycle of distributed keys, proposals, and signing ceremonies.
- Policy Engine enforces governance, authorizing operations before they are executed.
For a detailed breakdown of how API access and cryptographic signing interact, see Security and Authorization.
Prerequisites
Before starting, ensure you have:
- A Google Cloud Project. See Create projects to make one.
- If not using Cloud Shell, the
gcloudCLI installed and authenticated. Required APIs enabled, if not already done so. You need two, the new MPC API used in this guide, and Cloud Key Management Service if using it for assent key generation/signing.
gcloud services enable mpc.googleapis.com cloudkms.googleapis.comTo view enabled APIs:
gcloud services list --enabledPython 3.9+ and tutorial helper libraries installed:
pip install embit eth-account eth-utils requests(Optional) If your organization authenticates using an external Identity Provider (such as Okta or Microsoft Entra ID) without Google Accounts, see Configure Workforce Identity Federation (BYOID) to configure Application Default Credentials (ADC) with your federated identity.
Setup
Update the following variables to populate the commands throughout this guide.
Required Setup
You must customize these parameters. The guide won't function with the placeholder values.
| Parameter | Value | Description |
|---|---|---|
| Project ID | my-project |
The consumer project ID you've provided to us for onboarding onto the preview. |
| Domain ID | 67317436-0f29-ba9e-9146-d11c0bd8df9b |
The unique domain ID we've assigned to your instance. |
| Instance Path ID | mpc-unit-bob-123 |
The path instance ID we've assigned to your instance. |
| Location | us-central1 |
Deployment location. Must be us-central1 (other regions are not supported in preview). |
Tutorial Resource Labels
These are names for resources created during this tutorial. The default values are valid and can be kept to minimize setup friction.
| Parameter | Value | Description |
|---|---|---|
| Cloud KMS KeyRing ID | mpc-assent-keys |
Cloud Key Management Service KeyRing for external assent keys stored in Cloud KMS. |
| Signer 1 ID | signer-alice |
MPC Signer 1. |
| Signer 2 ID | signer-bob |
MPC Signer 2. |
| Signer 3 ID | signer-charlie |
MPC Signer 3. |
| Policy Author 1 ID | author-dave |
Policy Author 1. |
| Policy Author 2 ID | author-eve |
Policy Author 2. |
| Policy ID | ethereum-transfer-policy |
Policy ID (for example, ethereum-transfer-policy or bitcoin-psbt-policy). |
| KeyRing ID | my-key-ring |
KeyRing ID. |
| Key ID | my-key |
Key ID. |
| Proposal ID | my-proposal |
Proposal ID. |
Step 1: Generate Assent Keys
To use the MPC API, you must generate keypairs for the following distinct roles. Note, these are external cryptographic assent keys, and are distinct from the distributed MPC threshold key shards generated internally during setup.
- Policy Authors: Authorize governance policies.
- Signers: Provide assent (voting/approval) on proposals.
A. Participant Keys (secp256k1)
Generate the necessary cryptographic keys for your participants. These commands are idempotent.
Cloud KMS
We recommend managing assent keys in Cloud Key Management Service. Ensure your identity has permissions to create key rings, create keys, sign, and view public key verifiers (such as roles/cloudkms.admin and roles/cloudkms.signerVerifier). Ensure you have a KeyRing created, then generate a secp256k1 key for all your signers.
provision_keyring() {
if gcloud kms keyrings describe "mpc-assent-keys" --location="us-central1" > /dev/null 2>&1; then
echo "KeyRing 'mpc-assent-keys' already exists."
else
echo "KeyRing 'mpc-assent-keys' not found. Creating it..."
gcloud kms keyrings create "mpc-assent-keys" --location="us-central1"
fi
}
provision_kms_key() {
local id=$1
if gcloud kms keys describe "${id}-key" --keyring="mpc-assent-keys" --location="us-central1" > /dev/null 2>&1; then
echo "KMS key '${id}-key' already exists."
else
echo "KMS key '${id}-key' not found. Creating it..."
gcloud kms keys create "${id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--purpose=asymmetric-signing \
--default-algorithm=ec-sign-secp256k1-sha256 \
--protection-level=hsm
fi
gcloud kms keys versions get-public-key 1 \
--key="${id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--output-file="${id}_pub.pem"
echo "Retrieved public key from KMS and saved to ${id}_pub.pem:"
cat "${id}_pub.pem"
}
# Run Setup
provision_keyring
# Generate Keys
for ID in signer-alice signer-bob signer-charlie; do
echo "Generating KMS key for $ID..."
provision_kms_key "$ID"
done
OpenSSL
You can use standard OpenSSL to generate secp256k1 keys for each participant.
provision_openssl_key() {
local id=$1
if [[ ! -f "${id}_priv.pem" ]]; then
openssl ecparam -name secp256k1 -genkey -noout -out "${id}_priv.pem"
openssl ec -in "${id}_priv.pem" -pubout -out "${id}_pub.pem"
echo "Wrote private key to ${id}_priv.pem and public key to ${id}_pub.pem:"
cat "${id}_pub.pem"
else
echo "Private key file '${id}_priv.pem' already exists. Skipping OpenSSL generation."
echo "Using existing public key from '${id}_pub.pem':"
cat "${id}_pub.pem"
fi
}
for ID in signer-alice signer-bob signer-charlie; do
echo "Generating OpenSSL key for $ID..."
provision_openssl_key "$ID"
done
B. Policy Author Keys (secp256k1)
Cloud KMS
Similarly, we recommend managing these assent keys in Cloud Key Management Service. Ensure your identity has permissions to create key rings, create keys, sign, and view public key verifiers (such as roles/cloudkms.admin and roles/cloudkms.signerVerifier). Ensure you have a KeyRing created, then generate a secp256k1 key for all your Policy Authors.
provision_keyring() {
if gcloud kms keyrings describe "mpc-assent-keys" --location="us-central1" > /dev/null 2>&1; then
echo "KeyRing 'mpc-assent-keys' already exists."
else
echo "KeyRing 'mpc-assent-keys' not found. Creating it..."
gcloud kms keyrings create "mpc-assent-keys" --location="us-central1"
fi
}
provision_kms_key() {
local id=$1
if gcloud kms keys describe "${id}-key" --keyring="mpc-assent-keys" --location="us-central1" > /dev/null 2>&1; then
echo "KMS key '${id}-key' already exists."
else
echo "KMS key '${id}-key' not found. Creating it..."
gcloud kms keys create "${id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--purpose=asymmetric-signing \
--default-algorithm=ec-sign-secp256k1-sha256 \
--protection-level=hsm
fi
gcloud kms keys versions get-public-key 1 \
--key="${id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--output-file="${id}_pub.pem"
echo "Retrieved public key from KMS and saved to ${id}_pub.pem:"
cat "${id}_pub.pem"
}
provision_keyring
for ID in author-dave author-eve; do
echo "Generating KMS key for $ID..."
provision_kms_key "$ID"
done
OpenSSL
Policy Authors use the secp256k1 curve for signing governance policies and administrative actions. You can generate these keys using standard OpenSSL.
provision_openssl_key() {
local id=$1
if [[ ! -f "${id}_priv.pem" ]]; then
openssl ecparam -name secp256k1 -genkey -noout -out "${id}_priv.pem"
openssl ec -in "${id}_priv.pem" -pubout -out "${id}_pub.pem"
echo "Wrote private key to ${id}_priv.pem and public key to ${id}_pub.pem:"
cat "${id}_pub.pem"
else
echo "Private key file '${id}_priv.pem' already exists. Skipping OpenSSL generation."
echo "Using existing public key from '${id}_pub.pem':"
cat "${id}_pub.pem"
fi
}
for ID in author-dave author-eve; do
echo "Generating OpenSSL key for $ID..."
provision_openssl_key "$ID"
done
Step 2: Register Participants
Before any entity can participate in the MPC flow (as a Signer) or the Policy flow (as a Policy Author), they must be registered as a Participant.
Registration requires an out-of-band coordination between the Administrator and the Participant:
- The Entity (Signer or Author) generates their key pair locally and transfers their Public Key to the Administrator securely.
- The Administrator (holding standard Google Cloud IAM permissions) calls the API to register the Public Key.
curl
provision_participant() {
local id=$1
local name=$2
local pubkey_b64
# Read the public key from the file generated in Step 1
echo "Reading public key from ${id}_pub.pem..."
# Public key must be represented as Base64 encoded bytes of the PEM/DER format.
pubkey_b64=$(openssl base64 -A -in "${id}_pub.pem")
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers/$id"; then
echo "Participant '$id' already exists. Skipping registration."
else
echo "Participant '$id' not found. Registering..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"displayName\": \"$name\",
\"activePublicAssentKey\": {
\"publicKey\": \"$pubkey_b64\",
\"algorithm\": \"PUBLIC_KEY_SIGNATURE_ALGORITHM_ECDSA_SECP256K1\"
}
}" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers?participant_id=$id" | jq
fi
}
echo "Registering Signers..."
for ID in signer-alice signer-bob signer-charlie; do
provision_participant "$ID" "Signer $ID"
done
echo "Registering Policy Authors..."
for ID in author-dave author-eve; do
provision_participant "$ID" "Author $ID"
done
Step 3: Configure Policy Engine
Configure the Policy Engine by setting up the trust bundle and creating policies.
A. Initialize the Policy Author Trust Bundle
Define who the authorized Policy Authors are. This is a singleton resource for the instance.
curl
# Get current sequence from the API. If null (initial state), fallback to 0.
NEXT_BUNDLE_SEQUENCE=$(curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policyAuthorTrustBundle" \
| jq -r '.sequenceNumber // 0')
# Calculate higher sequence number.
NEXT_BUNDLE_SEQUENCE=$((NEXT_BUNDLE_SEQUENCE + 1))
curl -X PATCH \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"sequenceNumber": '"$NEXT_BUNDLE_SEQUENCE"',
"authors": [
{"authorId": "'"author-dave"'"},
{"authorId": "'"author-eve"'"}
]
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policyAuthorTrustBundle | jq
B. Create a Policy (PENDING_ACTIVE)
Define a Policy ID (ethereum-transfer-policy or bitcoin-psbt-policy) and create a rule describing what actions are permitted. While you can configure many policies to govern different scenarios, this guide demonstrates setting up a policy for either Ethereum or Bitcoin transactions. By default, the Policy Engine is Fail-Fast (Block by Default); if no policy explicitly permits a transaction, it is denied.
Before creating or approving a policy, authors must sign a payload representing the intent. You can use the following helper functions to handle binary serialization and signing.
Ethereum
This rule permits smaller Ethereum transfers (<= 2 ETH, represented as 2,000,000,000 Gwei) to a specific address for Alice.
1. Generate Quorum Signature
Using Cloud KMS:
sign_policy() {
local author_id=$1
local sequence=$2
local cedar_doc=$3
local policy_id=$4
export _HELPER_POLICY_ID="$policy_id"
export _HELPER_SEQUENCE="$sequence"
export _HELPER_CEDAR_DOC="$cedar_doc"
python3 -c "import os; p='my-project'; l='us-central1'; i='mpc-unit-bob-123'; pol=os.environ.get('_HELPER_POLICY_ID'); s=int(os.environ.get('_HELPER_SEQUENCE')); d=os.environ.get('_HELPER_CEDAR_DOC'); n=f'projects/{p}/locations/{l}/instances/{i}/policyEngine/policies/{pol}'; payload=b'action:ACTIVE\n'+n.encode('utf-8')+b'sequence:'+s.to_bytes(8,'little')+b'cedar_document:'+d.encode('utf-8'); open('payload.bin', 'wb').write(payload)"
gcloud kms asymmetric-sign \
--version=1 \
--key="${author_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=payload.bin \
--signature-file=policy_sig.bin > /dev/null
openssl base64 -A -in policy_sig.bin
}
# Sign as Author 1 (Sequence 0)
QUORUM_SIGNATURE=$(sign_policy "author-dave" 0 'permit(principal == User::"alice", action == Action::"Transfer", resource == Resource::"mpc_operation") when { context.transaction.to_address == "0x1111111111111111111111111111111111111111" && context.transaction.value_gwei <= 2000000000 };' "ethereum-transfer-policy")
Using OpenSSL:
sign_policy() {
local author_id=$1
local sequence=$2
local cedar_doc=$3
local policy_id=$4
export _HELPER_POLICY_ID="$policy_id"
export _HELPER_SEQUENCE="$sequence"
export _HELPER_CEDAR_DOC="$cedar_doc"
python3 -c "import os; p='my-project'; l='us-central1'; i='mpc-unit-bob-123'; pol=os.environ.get('_HELPER_POLICY_ID'); s=int(os.environ.get('_HELPER_SEQUENCE')); d=os.environ.get('_HELPER_CEDAR_DOC'); n=f'projects/{p}/locations/{l}/instances/{i}/policyEngine/policies/{pol}'; payload=b'action:ACTIVE\n'+n.encode('utf-8')+b'sequence:'+s.to_bytes(8,'little')+b'cedar_document:'+d.encode('utf-8'); open('payload.bin', 'wb').write(payload)"
openssl dgst -sha256 -sign "${author_id}_priv.pem" -out policy_sig.bin payload.bin
openssl base64 -A -in policy_sig.bin
}
# Sign as Author 1 (Sequence 0)
QUORUM_SIGNATURE=$(sign_policy "author-dave" 0 'permit(principal == User::"alice", action == Action::"Transfer", resource == Resource::"mpc_operation") when { context.transaction.to_address == "0x1111111111111111111111111111111111111111" && context.transaction.value_gwei <= 2000000000 };' "ethereum-transfer-policy")
2. Call CreatePolicy API
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/ethereum-transfer-policy"; then
echo "Policy 'ethereum-transfer-policy' already exists. Skipping creation."
else
echo "Policy 'ethereum-transfer-policy' not found. Creating..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Ethereum Transfer Permit Policy",
"rule": {
"cedarDocument": "permit(principal == User::\"alice\", action == Action::\"Transfer\", resource == Resource::\"mpc_operation\") when { context.transaction.to_address == \"0x1111111111111111111111111111111111111111\" && context.transaction.value_gwei <= 2000000000 };"
},
"createAuthorSignature": {
"authorId": "'"author-dave"'",
"signature": "'"$QUORUM_SIGNATURE"'",
"approvalTime": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"
}
}' \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies?policy_id=ethereum-transfer-policy" | jq
fi
Bitcoin
This rule permits Bitcoin transfers using the bitcoin.transaction.v2 schema (BIP 174 PSBT) where the net outbound transfer amount does not exceed 250,000 satoshis (context.transaction.spend_amount_scaled <= 2500000, or 0.0025 BTC normalized to 10-9 fixed decimal units) and the miner fee does not exceed 10,000 satoshis (context.transaction.max_fee_atomic <= 100000).
permit(
principal == User::"alice",
action == Action::"Transfer",
resource == Resource::"mpc_operation"
)
when {
context.transaction.schema == "bitcoin.transaction.v2" &&
context.transaction.spend_amount_scaled <= 2500000 &&
context.transaction.max_fee_atomic <= 100000
};
1. Generate Quorum Signature
Using Cloud KMS:
sign_policy() {
local author_id=$1
local sequence=$2
local cedar_doc=$3
local policy_id=$4
export _HELPER_POLICY_ID="$policy_id"
export _HELPER_SEQUENCE="$sequence"
export _HELPER_CEDAR_DOC="$cedar_doc"
python3 -c "import os; p='my-project'; l='us-central1'; i='mpc-unit-bob-123'; pol=os.environ.get('_HELPER_POLICY_ID'); s=int(os.environ.get('_HELPER_SEQUENCE')); d=os.environ.get('_HELPER_CEDAR_DOC'); n=f'projects/{p}/locations/{l}/instances/{i}/policyEngine/policies/{pol}'; payload=b'action:ACTIVE\n'+n.encode('utf-8')+b'sequence:'+s.to_bytes(8,'little')+b'cedar_document:'+d.encode('utf-8'); open('payload.bin', 'wb').write(payload)"
gcloud kms asymmetric-sign \
--version=1 \
--key="${author_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=payload.bin \
--signature-file=policy_sig.bin > /dev/null
openssl base64 -A -in policy_sig.bin
}
# Sign as Author 1 (Sequence 0)
QUORUM_SIGNATURE=$(sign_policy "author-dave" 0 'permit(principal == User::"alice", action == Action::"Transfer", resource == Resource::"mpc_operation") when { context.transaction.schema == "bitcoin.transaction.v2" && context.transaction.spend_amount_scaled <= 2500000 && context.transaction.max_fee_atomic <= 100000 };' "bitcoin-psbt-policy")
Using OpenSSL:
sign_policy() {
local author_id=$1
local sequence=$2
local cedar_doc=$3
local policy_id=$4
export _HELPER_POLICY_ID="$policy_id"
export _HELPER_SEQUENCE="$sequence"
export _HELPER_CEDAR_DOC="$cedar_doc"
python3 -c "import os; p='my-project'; l='us-central1'; i='mpc-unit-bob-123'; pol=os.environ.get('_HELPER_POLICY_ID'); s=int(os.environ.get('_HELPER_SEQUENCE')); d=os.environ.get('_HELPER_CEDAR_DOC'); n=f'projects/{p}/locations/{l}/instances/{i}/policyEngine/policies/{pol}'; payload=b'action:ACTIVE\n'+n.encode('utf-8')+b'sequence:'+s.to_bytes(8,'little')+b'cedar_document:'+d.encode('utf-8'); open('payload.bin', 'wb').write(payload)"
openssl dgst -sha256 -sign "${author_id}_priv.pem" -out policy_sig.bin payload.bin
openssl base64 -A -in policy_sig.bin
}
# Sign as Author 1 (Sequence 0)
QUORUM_SIGNATURE=$(sign_policy "author-dave" 0 'permit(principal == User::"alice", action == Action::"Transfer", resource == Resource::"mpc_operation") when { context.transaction.schema == "bitcoin.transaction.v2" && context.transaction.spend_amount_scaled <= 2500000 && context.transaction.max_fee_atomic <= 100000 };' "bitcoin-psbt-policy")
2. Call CreatePolicy API
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/bitcoin-psbt-policy"; then
echo "Policy 'bitcoin-psbt-policy' already exists. Skipping creation."
else
echo "Policy 'bitcoin-psbt-policy' not found. Creating..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Bitcoin PSBT Permit Policy",
"rule": {
"cedarDocument": "permit(principal == User::\"alice\", action == Action::\"Transfer\", resource == Resource::\"mpc_operation\") when { context.transaction.schema == \"bitcoin.transaction.v2\" && context.transaction.spend_amount_scaled <= 2500000 && context.transaction.max_fee_atomic <= 100000 };"
},
"createAuthorSignature": {
"authorId": "'"author-dave"'",
"signature": "'"$QUORUM_SIGNATURE"'",
"approvalTime": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"
}
}' \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies?policy_id=bitcoin-psbt-policy" | jq
fi
C. Approve Policy (Transition to ACTIVE)
If your policy setup requires M-of-N signatures, subsequent authors call AddQuorumSignature.
To generate the signature, you must first retrieve the current sequence of the policy.
Ethereum
# 1. Get the policy state and sequence
POLICY_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/ethereum-transfer-policy")
POLICY_STATE=$(echo "$POLICY_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$POLICY_STATE" == "PENDING_ACTIVE" ]; then
POLICY_SEQUENCE=$(echo "$POLICY_INFO" | jq -r '.globalSequence // 0')
echo "Policy Sequence: $POLICY_SEQUENCE"
# 2. Sign as Author 2
QUORUM_SIGNATURE_SEQ=$(sign_policy "author-eve" $POLICY_SEQUENCE 'permit(principal == User::"alice", action == Action::"Transfer", resource == Resource::"mpc_operation") when { context.transaction.to_address == "0x1111111111111111111111111111111111111111" && context.transaction.value_gwei <= 2000000000 };' "ethereum-transfer-policy")
# 3. Call AddQuorumSignature
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"name": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/ethereum-transfer-policy",
"signature": {
"authorId": "'"author-eve"'",
"signature": "'"$QUORUM_SIGNATURE_SEQ"'",
"approvalTime": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"
}
}' \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/ethereum-transfer-policy:addQuorumSignature" | jq
elif [ "$POLICY_STATE" == "ACTIVE" ]; then
echo "Policy 'ethereum-transfer-policy' is already ACTIVE. Skipping signature."
else
echo "Policy 'ethereum-transfer-policy' is in an unknown or unexpected state: $POLICY_STATE"
fi
Bitcoin
# 1. Get the policy state and sequence
POLICY_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/bitcoin-psbt-policy")
POLICY_STATE=$(echo "$POLICY_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$POLICY_STATE" == "PENDING_ACTIVE" ]; then
POLICY_SEQUENCE=$(echo "$POLICY_INFO" | jq -r '.globalSequence // 0')
echo "Policy Sequence: $POLICY_SEQUENCE"
# 2. Sign as Author 2
QUORUM_SIGNATURE_SEQ=$(sign_policy "author-eve" $POLICY_SEQUENCE 'permit(principal == User::"alice", action == Action::"Transfer", resource == Resource::"mpc_operation") when { context.transaction.schema == "bitcoin.transaction.v2" && context.transaction.spend_amount_scaled <= 2500000 && context.transaction.max_fee_atomic <= 100000 };' "bitcoin-psbt-policy")
# 3. Call AddQuorumSignature
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"name": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/bitcoin-psbt-policy",
"signature": {
"authorId": "'"author-eve"'",
"signature": "'"$QUORUM_SIGNATURE_SEQ"'",
"approvalTime": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"
}
}' \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/bitcoin-psbt-policy:addQuorumSignature" | jq
elif [ "$POLICY_STATE" == "ACTIVE" ]; then
echo "Policy 'bitcoin-psbt-policy' is already ACTIVE. Skipping signature."
else
echo "Policy 'bitcoin-psbt-policy' is in an unknown or unexpected state: $POLICY_STATE"
fi
Step 4: Create a Key
We now create a key ring and generate a distributed key.
A. Create KeyRing
Logical grouping for your MPC keys.
curl
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring"; then
echo "KeyRing 'my-key-ring' already exists. Skipping creation."
else
echo "KeyRing 'my-key-ring' not found. Creating..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings?key_ring_id=my-key-ring | jq
fi
B. Generate Distributed Key (DKG)
Initiate an asynchronous ceremony to generate a distributed key across participants.
curl
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key"; then
echo "Key 'my-key' already exists. Skipping generation."
else
echo "Key 'my-key' not found. Generating..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"threshold": 2,
"algorithm": "ECDSA_SECP256K1",
"participantShares": [
{"participantId": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers/signer-alice"},
{"participantId": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers/signer-bob"},
{"participantId": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers/signer-charlie"}
]
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys?key_id=my-key | jq
fi
This returns a long-running operation (LRO). You must wait for the Key state to become ACTIVE (which means DKG ceremony has succeeded).
🕵️♂️ Check Key State (Polling)
Poll the key status by running the following command periodically until the state field in the response becomes ACTIVE (indicating the DKG ceremony has succeeded).
curl
curl -X GET \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key | jq
C. Derive Blockchain Address & Locate UTXOs
After your distributed key becomes ACTIVE, the MPC key is ready to use. In this step, you derive the blockchain-specific address from the 65-byte uncompressed public key returned by the MPC key generation (0x04 || X || Y), and locate any unspent funds needed for transactions.
Ethereum
Ethereum addresses are derived from the Keccak-256 hash of the 64-byte uncompressed public key point (pub_bytes[1:]), taking the last 20 bytes.
Address Derivation Script (derive_address.py)
Execute the following block to create and run derive_address.py:
cat <<'EOF' > derive_address.py
import base64
from eth_utils import keccak, to_checksum_address
# Insert your base64 public_key from Step 4.B
PUBLIC_KEY_B64 = "your-public-key"
pub_bytes = base64.b64decode(PUBLIC_KEY_B64)
if len(pub_bytes) == 65 and pub_bytes[0] == 4:
raw_addr = "0x" + keccak(pub_bytes[1:])[-20:].hex()
checksummed_addr = to_checksum_address(raw_addr)
print("Ethereum Address:", checksummed_addr)
else:
print(f"Error: Expected 65-byte uncompressed key, got {len(pub_bytes)} bytes.")
EOF
python3 derive_address.py
Fund the Ethereum Address
Before broadcasting in Step 6, ensure your derived Ethereum address has enough Sepolia testnet ETH to cover the transaction value and gas costs. You can request free testnet ETH by entering your newly derived address into the Faucet for Ethereum Sepolia.
Bitcoin
Bitcoin addresses are derived from the uncompressed 65-byte secp256k1 EC public key (0x04 || X || Y). Multiple address formats are supported from the same public key:
- Native SegWit (P2WPKH / BIP 173): Bech32 address (
bc1q...on Mainnet,tb1q...on Testnet4) with a 22-byte witnessscriptPubKey(0014<hash160>). This is the recommended modern format with lowest transaction fees. - Nested SegWit (P2SH-P2WPKH / BIP 49): Wrapped SegWit address (
3...on Mainnet,2...on Testnet4) with a 23-bytescriptPubKey(a914<hash160>87) and a 22-byte redeemScript (0014<hash160>), compatible with legacy systems. - Legacy (P2PKH / BIP 44): Original Pay-to-PubKey-Hash Base58 address (
1...on Mainnet,m/n...on Testnet4) with a 25-bytescriptPubKey(76a914<hash160>88ac).
Address Derivation Script (derive_btc_address.py)
Execute the following block to create and run derive_btc_address.py:
cat <<'EOF' > derive_btc_address.py
"""Derives Bitcoin addresses (Native SegWit, Nested SegWit, Legacy) from an MPC public key using embit."""
import base64
from embit import ec, script
from embit.networks import NETWORKS
# Insert the Base64-encoded public key from Step 4.B (publicKey field, decodes to 65 raw bytes)
PUBLIC_KEY_B64 = "your-public-key"
raw_bytes = base64.b64decode(PUBLIC_KEY_B64)
if len(raw_bytes) != 65 or raw_bytes[0] != 4:
raise ValueError(f"Expected 65-byte uncompressed public key (0x04...), got {len(raw_bytes)} bytes")
# 1. Parse and compress public key to 33 bytes
pub = ec.PublicKey.parse(raw_bytes)
pub.compressed = True
compressed_pubkey_hex = pub.sec().hex()
print("================================================================")
print(f"Compressed Public Key (33 bytes): {compressed_pubkey_hex}")
print("================================================================")
# 2. Native SegWit (P2WPKH - BIP 173)
p2wpkh = script.p2wpkh(pub)
print("\n[1. Native SegWit (P2WPKH / Bech32 - BIP 173)]")
print(f" ScriptPubKey: {p2wpkh.data.hex()}")
print(f" Testnet4 Address (tb): {p2wpkh.address(NETWORKS['test'])}")
print(f" Mainnet Address (bc): {p2wpkh.address(NETWORKS['main'])}")
# 3. Nested SegWit (P2SH-P2WPKH - BIP 49)
p2sh_p2wpkh = script.p2sh(p2wpkh)
print("\n[2. Nested SegWit (P2SH-P2WPKH / Base58 - BIP 49)]")
print(f" RedeemScript: {p2wpkh.data.hex()}")
print(f" ScriptPubKey: {p2sh_p2wpkh.data.hex()}")
print(f" Testnet4 Address (2): {p2sh_p2wpkh.address(NETWORKS['test'])}")
print(f" Mainnet Address (3): {p2sh_p2wpkh.address(NETWORKS['main'])}")
# 4. Legacy (P2PKH - BIP 44)
p2pkh = script.p2pkh(pub)
print("\n[3. Legacy (P2PKH / Base58 - BIP 44)]")
print(f" ScriptPubKey: {p2pkh.data.hex()}")
print(f" Testnet4 Address (m/n): {p2pkh.address(NETWORKS['test'])}")
print(f" Mainnet Address (1): {p2pkh.address(NETWORKS['main'])}")
EOF
python3 derive_btc_address.py
Locating Unspent Outputs (UTXOs)
Unlike account-based blockchains, Bitcoin transactions spend specific unspent transaction outputs (UTXOs). Before constructing a transaction in Step 5:
- Fund your derived Native SegWit address (for example, using a testnet faucet).
- Query your address's unspent UTXOs using your Bitcoin node (
bitcoin-cli listunspent 1 9999999 "[\"tb1q...\"]") or a block explorer API. - Note the following 4 parameters for Step 5:
txid: The 32-byte transaction hash of the unspent output.vout: The zero-indexed output index (for example,0or1).value: The unspent amount in satoshis (for example,200000satoshis = 0.002 BTC).scriptPubKey: The locking script hex (for example,0014...).
Step 5: Execute an MPC Operation
Execute an MPC operation by evaluating a transaction and creating a proposal.
A. Evaluate Transaction (Get Authorization Token)
Before signing an MPC operation, authorize the payload against your policies to receive a JWS token and the 32-byte signing digest.
Ethereum
🛑 Case A: Rejection (Too High Value)
Attempt to authorize a transaction of 3 ETH (exceeds our 2 ETH limit).
# Payload for 3 ETH (Hex equivalent contains value_gwei > 2B)
RAW_TX_BYTES_HEX="ef808504a817c8008252089411111111111111111111111111111111111111118829a2241af62c00008083aa36a78080"
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"principal": "User::\"alice\"",
"action": "Action::\"Transfer\"",
"resource": "Resource::\"mpc_operation\"",
"schema": "ETHEREUM_TX_V1",
"payload": "'"$RAW_TX_BYTES_HEX"'"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine:authorizePayload | jq
Expected Response: decision: DENY
✅ Case B: Approval (Permitted Value)
Attempt to authorize a transaction of 1 Gwei (within our 2 ETH limit).
# Payload for 1 Gwei
RAW_TX_BYTES_HEX="eb808504a817c800825208941111111111111111111111111111111111111111843b9aca008083aa36a78080"
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"principal": "User::\"alice\"",
"action": "Action::\"Transfer\"",
"resource": "Resource::\"mpc_operation\"",
"schema": "ETHEREUM_TX_V1",
"payload": "'"$RAW_TX_BYTES_HEX"'"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine:authorizePayload)
echo "$RESPONSE" | jq
# Automatically capture variables for next steps
DIGEST_HEX=$(echo "$RESPONSE" | jq -r .digest)
POLICY_ENGINE_TOKEN=$(echo "$RESPONSE" | jq -r .token)
# Convert to Base64 (which curl expects for bytes fields)
DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
POLICY_ENGINE_TOKEN_B64=$(python3 -c "import base64; print(base64.b64encode('$POLICY_ENGINE_TOKEN'.encode('utf-8')).decode('utf-8'))")
Expected Response: decision: PERMIT along with a token.
Bitcoin
In the Bitcoin UTXO model, transactions spend specific unspent outputs. In SegWit (BIP 143), calculating the signature hash requires knowing the satoshi amount and locking script of the input being spent.
The Policy Engine requires BIP 174 PSBT v0 format (BITCOIN_TX_V2) with PSBT_IN_WITNESS_UTXO populated for each input.
PSBT Construction Script (build_psbt.py)
Execute the following block to assemble a valid BIP 174 PSBT v0 payload:
cat <<'EOF' > build_psbt.py
"""Constructs a BIP 174 PSBT v0 payload using embit for Bitcoin policy evaluation."""
from embit import script, transaction
from embit.psbt import PSBT
# 1. Inputs (From Step 4.C)
PREV_TXID_HEX = "8f594f33156993f1c980c2273dcae4a92cf2517fbc29264421395e63a63916d8"
PREV_VOUT = int("1")
INPUT_VALUE_SATS = int("200000") # Total UTXO input value in satoshis
SENDER_SCRIPTPUBKEY_HEX = "0014eced05cd51d964102a20c856b09080ad620e9da2"
# 2. Output Allocations
RECIPIENT_SATS = int("50000") # Recipient payment in satoshis
MINER_FEE_SATS = int("250") # Miner fee in satoshis
RECIPIENT_SCRIPTPUBKEY_HEX = "0014751e76e8199196d454941c45d1b3a323f1433bd6"
# Change Output calculation: InputSats - RecipientSats - MinerFeeSats
CHANGE_SATS = INPUT_VALUE_SATS - RECIPIENT_SATS - MINER_FEE_SATS
if CHANGE_SATS <= 0:
raise ValueError(f"Insufficient input satoshis ({INPUT_VALUE_SATS}) for payment and fee")
# 3. Construct Unsigned Transaction (Version 2, 1 input, 2 outputs, locktime 0)
tx = transaction.Transaction(
version=2,
vin=[transaction.TransactionInput(
txid=bytes.fromhex(PREV_TXID_HEX),
vout=PREV_VOUT,
sequence=0xFFFFFFFF,
)],
vout=[
transaction.TransactionOutput(
value=RECIPIENT_SATS,
script_pubkey=script.Script(bytes.fromhex(RECIPIENT_SCRIPTPUBKEY_HEX)),
),
transaction.TransactionOutput(
value=CHANGE_SATS,
script_pubkey=script.Script(bytes.fromhex(SENDER_SCRIPTPUBKEY_HEX)),
),
],
locktime=0,
)
# 4. Package into BIP 174 PSBT with Input Witness UTXO
psbt = PSBT(tx)
psbt.inputs[0].witness_utxo = transaction.TransactionOutput(
value=INPUT_VALUE_SATS,
script_pubkey=script.Script(bytes.fromhex(SENDER_SCRIPTPUBKEY_HEX)),
)
print(psbt.serialize().hex())
EOF
BTC_PSBT_HEX=$(python3 build_psbt.py)
echo "Generated PSBT Hex (${#BTC_PSBT_HEX} chars):"
echo "$BTC_PSBT_HEX"
🛑 Case A: Rejection (Fee Limit Exceeded)
Attempt to authorize a transaction where the miner fee exceeds the 10,000 satoshi policy limit. In this example, setting MINER_FEE_SATS = 15000 (and CHANGE_SATS = 135000) in build_psbt.py scales to max_fee_atomic = 150000 (exceeding the policy cap of 100000), producing the following payload that triggers a DENY decision from the Policy Engine.
# Construct a PSBT payload with miner_fee_sats = 15000 -> max_fee_atomic = 150000 (exceeds 100000 limit)
REJECTED_PSBT_HEX="70736274ff0100710200000001d81639a6635e3921442629bc7f51f22ca9e4ca3d27c280c9f1936915334f598f0100000000ffffffff0250c3000000000000160014751e76e8199196d454941c45d1b3a323f1433bd6580f020000000000160014eced05cd51d964102a20c856b09080ad620e9da2000000000001011f400d030000000000160014eced05cd51d964102a20c856b09080ad620e9da2000000"
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"principal": "User::\"alice\"",
"action": "Action::\"Transfer\"",
"resource": "Resource::\"mpc_operation\"",
"schema": "BITCOIN_TX_V2",
"payload": "'"$REJECTED_PSBT_HEX"'"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine:authorizePayload | jq
Expected Response: decision: DENY
✅ Case B: Approval (Permitted Bitcoin PSBT)
Attempt to authorize the previously constructed valid PSBT (RECIPIENT_SATS = 50000 maps to spend_amount_scaled = 500000 <= 2500000 and MINER_FEE_SATS = 250 maps to max_fee_atomic = 2500 <= 100000).
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"principal": "User::\"alice\"",
"action": "Action::\"Transfer\"",
"resource": "Resource::\"mpc_operation\"",
"schema": "BITCOIN_TX_V2",
"payload": "'"$BTC_PSBT_HEX"'"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine:authorizePayload)
echo "$RESPONSE" | jq
# Automatically capture variables for next steps
DIGEST_HEX=$(echo "$RESPONSE" | jq -r .digest)
POLICY_ENGINE_TOKEN=$(echo "$RESPONSE" | jq -r .token)
# Convert to Base64 (which curl expects for bytes fields)
DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
POLICY_ENGINE_TOKEN_B64=$(python3 -c "import base64; print(base64.b64encode('$POLICY_ENGINE_TOKEN'.encode('utf-8')).decode('utf-8'))")
Expected Response: decision: PERMIT along with a JWS token and 32-byte BIP 143 sighash digest.
This returns:
decision:PERMITorDENYtoken: The JWS policy token (Proof of compliance).digest: The hex-encoded 32-byte signing digest.evaluatedPolicyIds: Policies that were evaluated that affected the decision.
B. Create Proposal (Initiate Signing)
Offer the digest payload for signing, attaching the Policy Engine token received in Step 5A.
curl
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal"; then
echo "Proposal 'my-proposal' already exists. Skipping creation."
else
echo "Proposal 'my-proposal' not found. Creating..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"digest": "'"$DIGEST_B64"'",
"keyVersion": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/versions/1",
"initiator": "projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers/signer-alice",
"policyApproverSignature": "'"$POLICY_ENGINE_TOKEN_B64"'"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals?proposal_id=my-proposal | jq
fi
This returns a long-running operation (LRO). The expected Proposal state will be PENDING_ASSENT (indicating it is ready for participants to sign).
🕵️♂️ Check Proposal State (Polling)
Poll the proposal status by running the following command periodically.
curl
curl -X GET \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal | jq
C. Record Participant Decisions (Assent)
Participants must sign off on the proposal hash with their local private keys (Key Shares) and forward the assent.
1. Sign Proposal Digest
Cloud KMS
sign_digest() {
local signer_id=$1
local digest_hex=$2
local digest_b64=$(python3 -c "import base64, sys; print(base64.b64encode(bytes.fromhex('$digest_hex')).decode('utf-8'))")
# Direct API calls are used here because the input is already a pre-computed 32-byte digest from the MPC.
# Tools like gcloud assume input is raw data and apply a redundant hashing layer, altering the payload.
# Unlike Policy signings which hash raw administrative text, Proposal signatures must sign this exact 32-byte hash as-is.
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "Content-Type: application/json" \
-d "{\"digest\": {\"sha256\": \"$digest_b64\"}}" \
https://cloudkms.googleapis.com/v1/projects/my-project/locations/us-central1/keyRings/mpc-assent-keys/cryptoKeys/${signer_id}-key/cryptoKeyVersions/1:asymmetricSign | jq -r .signature
}
SIGNER_2_SIG_B64=$(sign_digest "signer-bob" "$DIGEST_HEX")
SIGNER_3_SIG_B64=$(sign_digest "signer-charlie" "$DIGEST_HEX")
OpenSSL
sign_digest() {
local signer_id=$1
local digest_hex=$2
python3 -c "import sys; sys.stdout.buffer.write(bytes.fromhex('$digest_hex'))" > digest.bin
openssl pkeyutl -sign -inkey "${signer_id}_priv.pem" -in digest.bin -out digest_sig.bin
openssl base64 -A -in digest_sig.bin
}
SIGNER_2_SIG_B64=$(sign_digest "signer-bob" "$DIGEST_HEX")
SIGNER_3_SIG_B64=$(sign_digest "signer-charlie" "$DIGEST_HEX")
2. Record Decision
curl
# 1. Check state for Signer 2
PROPOSAL_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal")
PROPOSAL_STATE=$(echo "$PROPOSAL_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$PROPOSAL_STATE" == "PENDING_ASSENT" ]; then
echo "Recording assent for Signer 2..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"signature": "'"$SIGNER_2_SIG_B64"'",
"decision": "ASSENT",
"participant": "signer-bob"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal:recordParticipantDecision | jq
elif [ "$PROPOSAL_STATE" == "READY" ] || [ "$PROPOSAL_STATE" == "SUCCEEDED" ]; then
echo "Proposal is in state $PROPOSAL_STATE. Skipping Signer 2."
else
echo "Proposal is in an unknown or unexpected state: $PROPOSAL_STATE"
fi
# 2. Re-check state for Signer 3 (Signer 2 might have triggered a state change)
PROPOSAL_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
"https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal")
PROPOSAL_STATE=$(echo "$PROPOSAL_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$PROPOSAL_STATE" == "PENDING_ASSENT" ]; then
echo "Recording assent for Signer 3..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"signature": "'"$SIGNER_3_SIG_B64"'",
"decision": "ASSENT",
"participant": "signer-charlie"
}' \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal:recordParticipantDecision | jq
elif [ "$PROPOSAL_STATE" == "READY" ] || [ "$PROPOSAL_STATE" == "SUCCEEDED" ]; then
echo "Proposal is in state $PROPOSAL_STATE. Skipping Signer 3."
else
echo "Proposal is in an unknown or unexpected state: $PROPOSAL_STATE"
fi
D. Fetch Final Signature
Once the threshold of assents is reached, check the proposal state or fetch the signature.
curl
curl -X GET \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal | jq
If the proposal is in a SUCCEEDED state, look into the finalSignature field inside the returned JSON.
Step 6: Broadcast Transaction to Blockchain
Once your proposal reaches the SUCCEEDED state and you have fetched the finalSignature, the last step is to construct the signed wire transaction and broadcast it to the network.
Ethereum
Recovery Identifier Derivation & Broadcasting
The MPC API returns a raw 64-byte ECDSA signature (comprising the r and s values). The server ensures that the signature is in canonical low-s form (EIP-2). However, Ethereum requires a Recovery Identifier (v) to derive the sender's address and verify the signature, which is not directly returned in standard 64-byte MPC signatures.
The following Python script handles this complexity. It dynamically determines the correct recovery bit by matching your expected sender address, constructs the valid transaction, and broadcasts it to the network.
Broadcast Script:
Execute the following copy-pastable block in your terminal to write and run broadcast_eth.py:
cat <<'EOF' > broadcast_eth.py
"""Broadcasts an Ethereum transaction using a normalized MPC signature."""
import base64
import os
import requests
from eth_account import Account
try:
from eth_account._utils.transactions import encode_transaction, serializable_unsigned_transaction_from_dict
except ImportError:
from eth_account._utils.legacy_transactions import encode_transaction, serializable_unsigned_transaction_from_dict
# ==========================================
# 1. INPUTS
# ==========================================
FINAL_SIGNATURE_B64 = "your-final-signature"
RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com"
EXPECTED_SENDER = "0xyour-mpc-key-address"
if not FINAL_SIGNATURE_B64 or FINAL_SIGNATURE_B64 == "your-final-signature":
raise ValueError("FINAL_SIGNATURE_B64 is not set. Please provide a valid signature.")
# ==========================================
# 2. TRANSACTION PARAMETERS
# ==========================================
CHAIN_ID = 11155111 # Sepolia Testnet
tx_dict = {
"nonce": int("0"),
"gasPrice": int("20000000000"), # 20 Gwei
"gas": int("21000"),
"to": "0x1111111111111111111111111111111111111111",
"value": int("1000000000"), # 1 Gwei
"data": b"",
"chainId": CHAIN_ID,
}
# ==========================================
# 3. DECODE & SPLIT SIGNATURE
# ==========================================
missing_padding = len(FINAL_SIGNATURE_B64) % 4
if missing_padding:
FINAL_SIGNATURE_B64 += "=" * (4 - missing_padding)
sig_bytes = base64.b64decode(FINAL_SIGNATURE_B64)
r = int.from_bytes(sig_bytes[:32], byteorder="big")
s = int.from_bytes(sig_bytes[32:], byteorder="big")
# ==========================================
# 4. RECOVERY IDENTIFIER (V) DERIVATION
# ==========================================
chain_v_base = (CHAIN_ID * 2) + 35
# ==========================================
# 5. GENERATE UNSIGNED TX & RECOVER SENDER ADDRESS
# ==========================================
unsigned_tx = serializable_unsigned_transaction_from_dict(tx_dict)
derived_sender = None
encoded_tx = None
final_v = chain_v_base
EXPECTED_SENDER_PLACEHOLDER = "0xyour-mpc-key-address"
is_valid_expected = EXPECTED_SENDER and EXPECTED_SENDER != EXPECTED_SENDER_PLACEHOLDER
for parity in [0, 1]:
try_v = chain_v_base + parity
try:
tx_candidate = encode_transaction(unsigned_tx, vrs=(try_v, r, s))
addr = Account.recover_transaction("0x" + tx_candidate.hex())
if is_valid_expected and EXPECTED_SENDER.lower() == addr.lower():
derived_sender = addr
final_v = try_v
encoded_tx = tx_candidate
break
if not is_valid_expected and derived_sender is None:
derived_sender = addr
final_v = try_v
encoded_tx = tx_candidate
print(f"Warning: EXPECTED_SENDER not provided or is placeholder. Assuming first recovered address: {addr}")
except Exception:
continue
if encoded_tx is None:
if is_valid_expected:
raise ValueError(f"Could not recover address matching EXPECTED_SENDER: {EXPECTED_SENDER}")
else:
raise ValueError("Could not derive broadcastable transaction. Ensure signature matches payload.")
if derived_sender:
print(f"Derived Sender Address: {derived_sender}")
else:
print("Derived Sender Address: Could not recover (ensure signature matches current transaction payload)")
valid_tx_hex = "0x" + encoded_tx.hex()
print(f"Broadcastable Transaction Hex:\n{valid_tx_hex}\n")
# ==========================================
# 6. BROADCAST TO NETWORK
# ==========================================
payload = {
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": [valid_tx_hex],
"id": 1,
}
print(f"Broadcasting to {RPC_URL}...")
response = requests.post(RPC_URL, json=payload)
resp_json = response.json()
print("RPC Response:", resp_json)
if "result" in resp_json:
tx_hash = resp_json["result"]
print(f"View transaction on Sepolia Etherscan: https://sepolia.etherscan.io/tx/{tx_hash}")
EOF
python3 broadcast_eth.py
Troubleshooting Common Errors
invalid sender: recovery failedorinvalid transaction v, r, s values: This occurs if the transaction parameters in your script don't exactly match the payload that was signed. Ensure thevalue,nonce, andtofields match what was processed in Step 5.insufficient funds for gas * price + value: Your transaction is cryptographically valid and was accepted by the node, but your MPC Key's Ethereum address does not have enough testnet ETH to cover the operation. You will need to fund your address before broadcasting.
Bitcoin
SegWit Wire Transaction Assembly & Broadcasting
For Bitcoin Native SegWit transactions (BIP 141), the raw 64-byte (r, s) MPC signature must be formatted into canonical low-S ASN.1 DER, appended with 0x01 (SIGHASH_ALL), and assembled with the 33-byte compressed public key into the 2-element SegWit witness stack: [DER signature || 0x01, compressed public key].
Broadcast Script:
Execute the following block to create and run broadcast_btc.py:
cat <<'EOF' > broadcast_btc.py
"""Assembles a signed Bitcoin Native SegWit transaction from an MPC signature using embit and broadcasts it."""
import base64
import urllib.request
from embit import ec, script, transaction
# ==========================================
# 1. INPUTS
# ==========================================
FINAL_SIGNATURE_B64 = "your-final-signature"
COMPRESSED_PUBKEY_HEX = "03deb76948c14003446a8c1d79e88fb3607c9bbc1f5143b186aa7b61d6b1facc36"
PREV_TXID_HEX = "8f594f33156993f1c980c2273dcae4a92cf2517fbc29264421395e63a63916d8"
PREV_VOUT = int("1")
RECIPIENT_SATS = int("50000")
RECIPIENT_SCRIPTPUBKEY_HEX = "0014751e76e8199196d454941c45d1b3a323f1433bd6"
CHANGE_SATS = int("149750")
CHANGE_SCRIPTPUBKEY_HEX = "0014eced05cd51d964102a20c856b09080ad620e9da2"
BROADCAST_API_URL = "https://mempool.space/testnet4/api/tx"
# ==========================================
# 2. CONVERT RAW (r, s) SIGNATURE TO CANONICAL LOW-S ASN.1 DER + SIGHASH_ALL
# ==========================================
sig_bytes = base64.b64decode(FINAL_SIGNATURE_B64)
internal_sig = ec.secp256k1.ecdsa_signature_parse_compact(sig_bytes)
ec.secp256k1.ecdsa_signature_normalize(internal_sig, internal_sig)
der_sig = ec.secp256k1.ecdsa_signature_serialize_der(internal_sig) + b"\x01" # 0x01 = SIGHASH_ALL
# ==========================================
# 3. BUILD TRANSACTION & ATTACH SEGWIT WITNESS STACK
# ==========================================
tx = transaction.Transaction(
version=2,
vin=[transaction.TransactionInput(
txid=bytes.fromhex(PREV_TXID_HEX),
vout=PREV_VOUT,
sequence=0xFFFFFFFF,
)],
vout=[
transaction.TransactionOutput(
value=RECIPIENT_SATS,
script_pubkey=script.Script(bytes.fromhex(RECIPIENT_SCRIPTPUBKEY_HEX)),
),
transaction.TransactionOutput(
value=CHANGE_SATS,
script_pubkey=script.Script(bytes.fromhex(CHANGE_SCRIPTPUBKEY_HEX)),
),
],
locktime=0,
)
pubkey_bytes = bytes.fromhex(COMPRESSED_PUBKEY_HEX)
tx.vin[0].witness = transaction.Witness([der_sig, pubkey_bytes])
signed_tx_hex = tx.serialize().hex()
print("--- Signed Bitcoin SegWit Wire Transaction Hex ---")
print(signed_tx_hex)
# ==========================================
# 4. BROADCAST TO NETWORK
# ==========================================
if BROADCAST_API_URL:
print(f"\nBroadcasting to {BROADCAST_API_URL}...")
try:
req = urllib.request.Request(
BROADCAST_API_URL,
data=signed_tx_hex.encode("utf-8"),
headers={"Content-Type": "text/plain"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
txid_result = resp.read().decode("utf-8").strip()
print(f"✅ Broadcast Successful! Transaction ID (txid): {txid_result}")
print(f"Track in Mempool Explorer: https://mempool.space/testnet4/tx/{txid_result}")
except Exception as e:
print(f"Broadcast response: {e}")
EOF
python3 broadcast_btc.py
Troubleshooting Common Errors
mandatory-script-verify-flag-failed (Signature must be zero for failed CHECK(MULTI)SIG operation)/non-mandatory-script-verify-flag (Non-canonical DER signature): Ensure thescomponent of the signature is in canonical low-s form (s <= N/2) and properly DER-encoded.bad-txns-in-belowout/bad-txns-fee-negative: Total outputs exceed input value. Ensure your change calculation (InputSatoshis - PaymentSatoshis - MinerFee) is strictly positive.
📖 Appendix: Operational Cookbook (Querying & Inspecting)
Use these read-only commands to inspect the state of your resources at any time.
👥 1. Participants & Signers
Inspect participants and signers in your instance.
A. List all registered participants
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers | jq
B. Get details for a specific participant
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/signers/signer-alice | jq
📜 2. Policy Engine
Inspect policies and trust bundles.
A. Get the Policy Author Trust Bundle
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policyAuthorTrustBundle | jq
B. List all Policies
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies | jq
C. Get details for a specific Policy
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/policyEngine/policies/ethereum-transfer-policy | jq
🔑 3. Keys & KeyRings
Inspect key rings and keys.
A. List all KeyRings
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings | jq
B. List all Keys in a KeyRing
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys | jq
C. Get status of a specific Distributed Key (DKG Status)
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key | jq
📝 4. Proposals (Signing Operations)
Inspect proposals and signing status.
A. List all Proposals for a Key
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals | jq
B. Get details or status of a specific Proposal
curl
curl -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "X-Goog-User-Project: my-project" \
https://67317436-0f29-ba9e-9146-d11c0bd8df9b.us-central1.mpc.goog/v1alpha1/projects/my-project/locations/us-central1/instances/mpc-unit-bob-123/mpc/keyRings/my-key-ring/keys/my-key/proposals/my-proposal | jq
Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies. Java is a registered trademark of Oracle and/or its affiliates.
Last updated 2026-10-27 UTC.