This guide walks through the user journeys of the Multi-party Compute API to manage distributed keys, users, teams, and policies. It is intended for preview customers learning the key user journeys.
Overview
The MPC API manages the lifecycle of instances, teams, users, key rings, distributed keys, and threshold signing ceremonies, with built-in cryptographic governance enforced across three policy layers:
- Administration Policies (Role Quorums): Admins onboard Users and Teams, and a quorum of Admins (
EnableUserRoleandApproveRoleChange) is required to grant or revoke the Admin and Policy Author roles. Granting the Signer role takes effect immediately from a single Admin because Signer alone grants no access to existing keys; a Signer only receives key shares when included in a KeyRing's KeyPolicy during key generation. - Key Policies (
KeyRing.keyPolicy): Admins define a KeyRing's KeyPolicy when creating the KeyRing, specifying which Signers (Signer, grouped into Teams) hold key shares and the K-of-N signer approvals (CreateTransactionandRecordSignerDecision) required to authorize a signature. - Transaction Policies (
TransactionPolicyandKeyList): A quorum of Policy Authors (POLICY_AUTHOR) proposes, approves, or disables Cedarpermitandforbidrules (TransactionPolicy) andKeyListaddress allowlists. EveryCreateTransactionrequest is evaluated against active transaction policies (PERMITorDENY) before signing.
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 MPC API used in this guide, and Cloud Key Management Service if using it for authorization key generation and 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 for your MPC instance. |
| Instance ID | my-mpc-instance |
The ID for your MPC instance ([a-z]([a-z0-9-]{0,61}[a-z0-9])?). |
| Location | us-central1 |
Deployment location (for example, us-central1). |
| Admin 1 Principal | admin-1@example.com |
Google Cloud email or BYOID principal of the first ADMIN user in the instance. |
| Admin 2 Principal | admin-2@example.com |
Google Cloud email or BYOID principal of the second ADMIN user in the instance. |
| Signer 1 Principal | alice@example.com |
Google Cloud email or BYOID principal of Signer 1 (Alice). |
| Signer 2 Principal | bob@example.com |
Google Cloud email or BYOID principal of Signer 2 (Bob). |
| Signer 3 Principal | charlie@example.com |
Google Cloud email or BYOID principal of Signer 3 (Charlie). |
| Policy Author 1 Principal | dave@example.com |
Google Cloud email or BYOID principal of Policy Author 1 (Dave). |
| Policy Author 2 Principal | eve@example.com |
Google Cloud email or BYOID principal of Policy Author 2 (Eve). |
Authenticate each account once in your terminal session so gcloud auth print-access-token "<principal>" can retrieve tokens for any persona without switching accounts mid-tutorial (ADMIN_1_PRINCIPAL is authenticated last so it remains the active gcloud CLI account for IAM and Cloud KMS commands):
for PRINCIPAL in \
"admin-2@example.com" \
"alice@example.com" \
"bob@example.com" \
"charlie@example.com" \
"dave@example.com" \
"eve@example.com" \
"admin-1@example.com"; do
gcloud auth login "$PRINCIPAL"
done
Tutorial Resource Labels
These are local identifiers for the authorization keys created using Cloud KMS during this tutorial. The default values are valid and can be kept to minimize setup friction. Note that resource IDs for Team, User, TransactionPolicy, KeyRing, Key, KeyList, and Transaction resources are server-generated and captured into shell variables (TEAM_NAME, SIGNER_1_NAME, POLICY_NAME, KEY_RING_NAME, KEY_NAME, KEY_LIST_NAME, TRANSACTION_NAME) when each resource is created.
| Parameter | Value | Description |
|---|---|---|
| Cloud KMS KeyRing ID | mpc-assent-keys |
Cloud Key Management Service KeyRing for external authorization keys stored in Cloud KMS. |
| Cloud KMS Admin 1 ID | admin-1 |
MPC Admin 1 authorization key label. |
| Cloud KMS Admin 2 ID | admin-2 |
MPC Admin 2 authorization key label. |
| Cloud KMS Signer 1 ID | signer-alice |
MPC Signer 1 authorization key label. |
| Cloud KMS Signer 2 ID | signer-bob |
MPC Signer 2 authorization key label. |
| Cloud KMS Signer 3 ID | signer-charlie |
MPC Signer 3 authorization key label. |
| Cloud KMS Policy Author 1 ID | author-dave |
Policy Author 1 authorization key label. |
| Cloud KMS Policy Author 2 ID | author-eve |
Policy Author 2 authorization key label. |
Step 1: Create an MPC Instance
An MPC Instance represents your dedicated, isolated MPC environment. Creating and administering an instance requires the Multi-party Compute Admin (roles/mpc.admin) IAM role on your project. You create and inspect the Instance as an Admin using MPCInstanceService on the control plane endpoint (https://mpc.googleapis.com), and send all subsequent resource requests to the instance's dedicated data plane URL (INSTANCE_URL).
A. Create the Instance
First, ensure your Admin principals (admin-1@example.com and admin-2@example.com) have roles/mpc.admin on the project, and call instances.create (CreateInstance) as Admin 1 to provision your MPC instance:
curl
grant_iam_role() {
local principal=$1
local iam_role=$2
local member="user:${principal}"
# BYOID principals already start with "principal://"
if [[ "$principal" == principal://* ]]; then
member="${principal}"
fi
echo "Granting ${iam_role} to ${member}..."
gcloud projects add-iam-policy-binding "my-project" \
--member="${member}" \
--role="${iam_role}" \
--condition=None > /dev/null
}
for ADMIN_PRINCIPAL in "admin-1@example.com" "admin-2@example.com"; do
grant_iam_role "$ADMIN_PRINCIPAL" "roles/mpc.admin"
done
export INSTANCE_NAME="projects/my-project/locations/us-central1/instances/my-mpc-instance"
# Check existence
if curl -s -f -o /dev/null -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"https://mpc.googleapis.com/v1beta/${INSTANCE_NAME}"; then
echo "Instance 'my-mpc-instance' already exists. Skipping creation."
else
echo "Instance 'my-mpc-instance' not found. Creating..."
curl -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Tutorial MPC Instance"
}' \
"https://mpc.googleapis.com/v1beta/projects/my-project/locations/us-central1/instances?instanceId=my-mpc-instance" | jq
fi
This returns a long-running operation (LRO). You must wait for the Instance state to become ACTIVE before calling its data plane URL.
🕵️♂️ Check Instance State (Polling)
Poll the instance status until state becomes ACTIVE, and save its url field to INSTANCE_URL:
curl
INSTANCE_INFO=$(curl -s -X GET \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"https://mpc.googleapis.com/v1beta/${INSTANCE_NAME}")
echo "$INSTANCE_INFO" | jq
export INSTANCE_URL=$(echo "$INSTANCE_INFO" | jq -r '.url')
echo "Instance Data Plane URL: $INSTANCE_URL"
Step 2: Generate Authorization Keys
To use the MPC API, you must generate keypairs for the following distinct roles. Note that these are external cryptographic authorization keys (User.authorizationKey), meaning you generate and custody them outside the MPC instance, and therefore they are distinct from the MPC key shares generated during key creation:
- Admins: Authorize changes to
ADMINandPOLICY_AUTHORuser roles, and createKeys. - Policy Authors: Authorize governance policies (
TransactionPolicy). - Signers: Provide authorization (
AUTHORIZEorDENY) on transactions.
Generate the secp256k1 cryptographic authorization keys for your Signers, Policy Authors, and Admins. These commands are idempotent.
Cloud KMS
We recommend managing authorization 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 each Signer, Policy Author, and Admin.
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 Signers, Policy Authors, and Admins
for ID in \
signer-alice \
signer-bob \
signer-charlie \
author-dave \
author-eve \
admin-1 \
admin-2; do
echo "Generating KMS key for $ID..."
provision_kms_key "$ID"
done
OpenSSL
You can use standard OpenSSL to generate secp256k1 keys for each Signer, Policy Author, and Admin.
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 \
author-dave \
author-eve \
admin-1 \
admin-2; do
echo "Generating OpenSSL key for $ID..."
provision_openssl_key "$ID"
done
Step 3: Register Teams and Users
Before any entity can participate in transaction signing (as a SIGNER) or policy authoring (as a POLICY_AUTHOR), they must be granted IAM permissions on the project and registered as an application-level User in the instance (see Security and authorization for how IAM roles and User roles work together).
Registration requires out-of-band coordination between the Administrator and the User:
- The Entity (Signer or Policy Author) generates their key pair locally and transfers their Public Key to the Administrator securely.
- The Administrator grants the User's principal the appropriate IAM role on the project (
roles/mpc.editorfor Signers and Policy Authors;roles/mpc.adminfor Admins) and calls the MPC API to register the User'sprincipalandauthorizationKey.
A. Create a Signer Team
Every User with the SIGNER role must belong to a Team, and all other Users with other roles cannot belong to a Team. As Admin 1 (admin-1@example.com), create a "Treasury Signers" Team (CreateTeam) to hold your three signers and capture its server-generated resource name:
curl
TEAM_RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Treasury Signers",
}' \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/teams?requestId=11111111-1111-4111-8111-111111111101")
echo "$TEAM_RESPONSE" | jq
export TEAM_NAME=$(echo "$TEAM_RESPONSE" | jq -r '.name')
echo "Created Team: $TEAM_NAME"
B. Grant IAM Roles and Register Signers and Policy Authors
Each user requires both a project-level IAM role (roles/mpc.editor for Signers and Policy Authors, and roles/mpc.admin for Admins) to call the MPC API and an application-level User resource inside the instance. When calling users.create (CreateUser), the roles field can only be empty or ["SIGNER"]:
- Signers: Granted
roles/mpc.editorin IAM and created withroles: ["SIGNER"]andteam: "$TEAM_NAME". TheSIGNERrole becomesACTIVEimmediately without requiring Admin quorum approval, because having theSIGNERrole alone does not grant access to any existing keys; a Signer only receives key shares when included in aKeyRing'sKeyPolicyduring key generation. Policy Authors: Granted
roles/mpc.editorin IAM and created with no initial roles and noteam, then granted thePOLICY_AUTHORrole in Step 3.C using Admin quorum approval (sincePOLICY_AUTHORandADMINhave instance-wide governance authority).
curl
provision_user() {
local id=$1
local display_name=$2
local principal=$3
local request_id=$4
local team=$5
local pubkey_b64
local signer_fields=""
# 1. Grant project-level IAM permission (roles/mpc.editor) to the User's principal
grant_iam_role "$principal" "roles/mpc.editor" >&2
# 2. Read the public key from the file generated in Step 2 (Base64-encoded PEM/DER bytes)
pubkey_b64=$(openssl base64 -A -in "${id}_pub.pem")
if [[ -n "$team" ]]; then
signer_fields=", \"roles\": [\"SIGNER\"], \"team\": \"${team}\""
fi
# 3. Register the application-level User in the MPC instance as Admin 1
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"displayName\": \"${display_name}\",
\"principal\": \"${principal}\",
\"authorizationKey\": {
\"publicKey\": \"${pubkey_b64}\",
\"algorithm\": \"PUBLIC_KEY_SIGNATURE_ALGORITHM_ECDSA_SECP256K1\"
}${signer_fields}
}" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/users?requestId=${request_id}"
}
echo "Registering Signers..."
export SIGNER_1_NAME=$(provision_user "signer-alice" "Signer Alice" "alice@example.com" "11111111-1111-4111-8111-111111111001" "${TEAM_NAME}" | tee /dev/stderr | jq -r '.name')
export SIGNER_2_NAME=$(provision_user "signer-bob" "Signer Bob" "bob@example.com" "11111111-1111-4111-8111-111111111002" "${TEAM_NAME}" | tee /dev/stderr | jq -r '.name')
export SIGNER_3_NAME=$(provision_user "signer-charlie" "Signer Charlie" "charlie@example.com" "11111111-1111-4111-8111-111111111003" "${TEAM_NAME}" | tee /dev/stderr | jq -r '.name')
echo "Registering Policy Authors..."
export POLICY_AUTHOR_1_NAME=$(provision_user "author-dave" "Author Dave" "dave@example.com" "11111111-1111-4111-8111-111111111004" | tee /dev/stderr | jq -r '.name')
export POLICY_AUTHOR_2_NAME=$(provision_user "author-eve" "Author Eve" "eve@example.com" "11111111-1111-4111-8111-111111111005" | tee /dev/stderr | jq -r '.name')
C. Grant the Policy Author Role (Admin Quorum Approval)
Enabling or disabling the POLICY_AUTHOR or ADMIN role on a User requires a quorum of Admins:
- Admin 1 calls
users.enableRole(EnableUserRole), which moves the role toPENDING_ACTIVEand records Admin 1's signature as the first approval. - Admin 2 calls
users.approveRoleChange(ApproveRoleChange) with their signature, transitioning the role toACTIVE.
Cloud KMS
1. Request POLICY_AUTHOR as Admin 1 (EnableUserRole)
sign_role_change() {
local admin_id=$1
local target_id=$2
local principal=$3
python3 -c 'import sys; inst, prin, pub = sys.argv[1], sys.argv[2], open(sys.argv[3], "rb").read(); v = lambda n: bytes([n & 127 | 128]) + v(n >> 7) if n > 127 else bytes([n]); f = lambda k, b: bytes([k << 3 | 2]) + v(len(b)) + b; open("role_payload.bin", "wb").write(f(1, inst.encode()) + f(2, prin.encode()) + f(3, f(1, pub) + b"\x10\x01") + f(4, b"\x08\x02\x10\x01\x18\x01"))' "${INSTANCE_NAME}" "${principal}" "${target_id}_pub.pem"
gcloud kms asymmetric-sign \
--version=1 \
--key="${admin_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=role_payload.bin \
--signature-file=role_sig.bin > /dev/null
openssl base64 -A -in role_sig.bin
}
enable_policy_author() {
local target_user=$1
local target_id=$2
local target_principal=$3
echo "Admin 1 requesting POLICY_AUTHOR for ${target_user}..."
local admin_1_sig=$(sign_role_change "admin-1" "${target_id}" "${target_principal}")
local signed_payload_b64=$(openssl base64 -A -in role_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"role\": \"POLICY_AUTHOR\",
\"authorizationInfo\": {
\"signature\": \"${admin_1_sig}\",
\"signedPayload\": \"${signed_payload_b64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${target_user}:enableRole" | jq
}
enable_policy_author "${POLICY_AUTHOR_1_NAME}" "author-dave" "dave@example.com"
enable_policy_author "${POLICY_AUTHOR_2_NAME}" "author-eve" "eve@example.com"
2. Approve POLICY_AUTHOR as Admin 2 (ApproveRoleChange)
approve_policy_author() {
local target_user=$1
local target_id=$2
local target_principal=$3
echo "Admin 2 approving POLICY_AUTHOR for ${target_user}..."
local admin_2_sig=$(sign_role_change "admin-2" "${target_id}" "${target_principal}")
local signed_payload_b64=$(openssl base64 -A -in role_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-2@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"authorizationInfo\": {
\"signature\": \"${admin_2_sig}\",
\"signedPayload\": \"${signed_payload_b64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${target_user}:approveRoleChange" | jq
}
approve_policy_author "${POLICY_AUTHOR_1_NAME}" "author-dave" "dave@example.com"
approve_policy_author "${POLICY_AUTHOR_2_NAME}" "author-eve" "eve@example.com"
OpenSSL
1. Request POLICY_AUTHOR as Admin 1 (EnableUserRole)
sign_role_change() {
local admin_id=$1
local target_id=$2
local principal=$3
python3 -c 'import sys; inst, prin, pub = sys.argv[1], sys.argv[2], open(sys.argv[3], "rb").read(); v = lambda n: bytes([n & 127 | 128]) + v(n >> 7) if n > 127 else bytes([n]); f = lambda k, b: bytes([k << 3 | 2]) + v(len(b)) + b; open("role_payload.bin", "wb").write(f(1, inst.encode()) + f(2, prin.encode()) + f(3, f(1, pub) + b"\x10\x01") + f(4, b"\x08\x02\x10\x01\x18\x01"))' "${INSTANCE_NAME}" "${principal}" "${target_id}_pub.pem"
openssl dgst -sha256 -sign "${admin_id}_priv.pem" -out role_sig.bin role_payload.bin
openssl base64 -A -in role_sig.bin
}
enable_policy_author() {
local target_user=$1
local target_id=$2
local target_principal=$3
echo "Admin 1 requesting POLICY_AUTHOR for ${target_user}..."
local admin_1_sig=$(sign_role_change "admin-1" "${target_id}" "${target_principal}")
local signed_payload_b64=$(openssl base64 -A -in role_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"role\": \"POLICY_AUTHOR\",
\"authorizationInfo\": {
\"signature\": \"${admin_1_sig}\",
\"signedPayload\": \"${signed_payload_b64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${target_user}:enableRole" | jq
}
enable_policy_author "${POLICY_AUTHOR_1_NAME}" "author-dave" "dave@example.com"
enable_policy_author "${POLICY_AUTHOR_2_NAME}" "author-eve" "eve@example.com"
2. Approve POLICY_AUTHOR as Admin 2 (ApproveRoleChange)
approve_policy_author() {
local target_user=$1
local target_id=$2
local target_principal=$3
echo "Admin 2 approving POLICY_AUTHOR for ${target_user}..."
local admin_2_sig=$(sign_role_change "admin-2" "${target_id}" "${target_principal}")
local signed_payload_b64=$(openssl base64 -A -in role_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-2@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"authorizationInfo\": {
\"signature\": \"${admin_2_sig}\",
\"signedPayload\": \"${signed_payload_b64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${target_user}:approveRoleChange" | jq
}
approve_policy_author "${POLICY_AUTHOR_1_NAME}" "author-dave" "dave@example.com"
approve_policy_author "${POLICY_AUTHOR_2_NAME}" "author-eve" "eve@example.com"
Step 4: Configure Transaction Policies
Configure transaction policies by creating and approving a TransactionPolicy (and optionally a KeyList).
A. Create a Policy (PENDING_ACTIVE)
Create a Cedar policy rule describing what transactions 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, transaction policy evaluation is Fail-Fast (Block by Default); if no active permit policy matches a transaction, the policy evaluation decision is DENY. For full details on Cedar attributes, see Write transaction policy rules.
Ethereum, KMS
This rule permits smaller Ethereum transfers (<= 2 ETH, represented as 2,000,000,000 in units of 10-9 ETH / Gwei) to a specific recipient address (0x1111111111111111111111111111111111111111).
permit (principal, action, resource)
when {
context.schema == "ETHEREUM_TX_V2" &&
context.secondary_key == "0x1111111111111111111111111111111111111111" &&
context has amount_token_scaled &&
context.amount_token_scaled <= 2000000000
};
sign_policy() {
local author_id=$1
local resource_type=$2
local resource_name=$3
local action=$4
local cedar_doc=$5
python3 -c 'import sys; rt, rn, act, doc = sys.argv[1:]; v = lambda n: bytes([n & 127 | 128]) + v(n >> 7) if n > 127 else bytes([n]); f = lambda k, b: bytes([k << 3 | 2]) + v(len(b)) + b; open("policy_payload.bin", "wb").write(f(1 if rt == "instance" else 2, rn.encode()) + bytes([24, 1 if act == "ACTIVATE" else 2]) + f(4, f(1, doc.encode())))' "${resource_type}" "${resource_name}" "${action}" "${cedar_doc}"
gcloud kms asymmetric-sign \
--version=1 \
--key="${author_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=policy_payload.bin \
--signature-file=policy_sig.bin > /dev/null
openssl base64 -A -in policy_sig.bin
}
export CEDAR_RULE='permit (principal, action, resource) when { context.schema == "ETHEREUM_TX_V2" && context.secondary_key == "0x1111111111111111111111111111111111111111" && context has amount_token_scaled && context.amount_token_scaled <= 2000000000 };'
# Sign as Policy Author 1
QUORUM_SIGNATURE=$(sign_policy "author-dave" "instance" "${INSTANCE_NAME}" "ACTIVATE" "${CEDAR_RULE}")
POLICY_PAYLOAD_B64=$(openssl base64 -A -in policy_payload.bin)
POLICY_RESPONSE=$(jq -n \
--arg rule "$CEDAR_RULE" \
--arg sig "$QUORUM_SIGNATURE" \
--arg payload "$POLICY_PAYLOAD_B64" \
'{
transactionPolicy: {
displayName: "Ethereum Transfer Permit Policy",
rule: {cedarDocument: $rule}
},
authorizationInfo: {
signature: $sig,
signedPayload: $payload
},
requestId: "11111111-1111-4111-8111-111111111201"
}' \
| curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "dave@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d @- \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/transactionPolicies")
echo "$POLICY_RESPONSE" | jq
export POLICY_NAME=$(echo "$POLICY_RESPONSE" | jq -r '.name')
Ethereum, OpenSSL
This rule permits smaller Ethereum transfers (<= 2 ETH, represented as 2,000,000,000 in units of 10-9 ETH / Gwei) to a specific recipient address (0x1111111111111111111111111111111111111111).
permit (principal, action, resource)
when {
context.schema == "ETHEREUM_TX_V2" &&
context.secondary_key == "0x1111111111111111111111111111111111111111" &&
context has amount_token_scaled &&
context.amount_token_scaled <= 2000000000
};
sign_policy() {
local author_id=$1
local resource_type=$2
local resource_name=$3
local action=$4
local cedar_doc=$5
python3 -c 'import sys; rt, rn, act, doc = sys.argv[1:]; v = lambda n: bytes([n & 127 | 128]) + v(n >> 7) if n > 127 else bytes([n]); f = lambda k, b: bytes([k << 3 | 2]) + v(len(b)) + b; open("policy_payload.bin", "wb").write(f(1 if rt == "instance" else 2, rn.encode()) + bytes([24, 1 if act == "ACTIVATE" else 2]) + f(4, f(1, doc.encode())))' "${resource_type}" "${resource_name}" "${action}" "${cedar_doc}"
openssl dgst -sha256 -sign "${author_id}_priv.pem" -out policy_sig.bin policy_payload.bin
openssl base64 -A -in policy_sig.bin
}
export CEDAR_RULE='permit (principal, action, resource) when { context.schema == "ETHEREUM_TX_V2" && context.secondary_key == "0x1111111111111111111111111111111111111111" && context has amount_token_scaled && context.amount_token_scaled <= 2000000000 };'
# Sign as Policy Author 1
QUORUM_SIGNATURE=$(sign_policy "author-dave" "instance" "${INSTANCE_NAME}" "ACTIVATE" "${CEDAR_RULE}")
POLICY_PAYLOAD_B64=$(openssl base64 -A -in policy_payload.bin)
POLICY_RESPONSE=$(jq -n \
--arg rule "$CEDAR_RULE" \
--arg sig "$QUORUM_SIGNATURE" \
--arg payload "$POLICY_PAYLOAD_B64" \
'{
transactionPolicy: {
displayName: "Ethereum Transfer Permit Policy",
rule: {cedarDocument: $rule}
},
authorizationInfo: {
signature: $sig,
signedPayload: $payload
},
requestId: "11111111-1111-4111-8111-111111111201"
}' \
| curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "dave@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d @- \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/transactionPolicies")
echo "$POLICY_RESPONSE" | jq
export POLICY_NAME=$(echo "$POLICY_RESPONSE" | jq -r '.name')
Bitcoin, KMS
This rule permits Bitcoin transfers using the BITCOIN_TX_V2 schema (BIP 174 PSBT v0) where the net outbound transfer amount does not exceed 250,000 satoshis (context.amount_token_scaled <= 2500000, or 0.0025 BTC normalized to 10-9 fixed decimal units) and the miner fee does not exceed 10,000 satoshis (context.max_fee_atomic <= 100000).
permit (principal, action, resource)
when {
context.schema == "BITCOIN_TX_V2" &&
context has amount_token_scaled &&
context.amount_token_scaled <= 2500000 &&
context.max_fee_atomic <= 100000
};
sign_policy() {
local author_id=$1
local resource_type=$2
local resource_name=$3
local action=$4
local cedar_doc=$5
python3 -c 'import sys; rt, rn, act, doc = sys.argv[1:]; v = lambda n: bytes([n & 127 | 128]) + v(n >> 7) if n > 127 else bytes([n]); f = lambda k, b: bytes([k << 3 | 2]) + v(len(b)) + b; open("policy_payload.bin", "wb").write(f(1 if rt == "instance" else 2, rn.encode()) + bytes([24, 1 if act == "ACTIVATE" else 2]) + f(4, f(1, doc.encode())))' "${resource_type}" "${resource_name}" "${action}" "${cedar_doc}"
gcloud kms asymmetric-sign \
--version=1 \
--key="${author_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=policy_payload.bin \
--signature-file=policy_sig.bin > /dev/null
openssl base64 -A -in policy_sig.bin
}
export CEDAR_RULE='permit (principal, action, resource) when { context.schema == "BITCOIN_TX_V2" && context has amount_token_scaled && context.amount_token_scaled <= 2500000 && context.max_fee_atomic <= 100000 };'
# Sign as Policy Author 1
QUORUM_SIGNATURE=$(sign_policy "author-dave" "instance" "${INSTANCE_NAME}" "ACTIVATE" "${CEDAR_RULE}")
POLICY_PAYLOAD_B64=$(openssl base64 -A -in policy_payload.bin)
POLICY_RESPONSE=$(jq -n \
--arg rule "$CEDAR_RULE" \
--arg sig "$QUORUM_SIGNATURE" \
--arg payload "$POLICY_PAYLOAD_B64" \
'{
transactionPolicy: {
displayName: "Bitcoin PSBT Permit Policy",
rule: {cedarDocument: $rule}
},
authorizationInfo: {
signature: $sig,
signedPayload: $payload
},
requestId: "11111111-1111-4111-8111-111111111202"
}' \
| curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "dave@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d @- \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/transactionPolicies")
echo "$POLICY_RESPONSE" | jq
export POLICY_NAME=$(echo "$POLICY_RESPONSE" | jq -r '.name')
Bitcoin, OpenSSL
This rule permits Bitcoin transfers using the BITCOIN_TX_V2 schema (BIP 174 PSBT v0) where the net outbound transfer amount does not exceed 250,000 satoshis (context.amount_token_scaled <= 2500000, or 0.0025 BTC normalized to 10-9 fixed decimal units) and the miner fee does not exceed 10,000 satoshis (context.max_fee_atomic <= 100000).
permit (principal, action, resource)
when {
context.schema == "BITCOIN_TX_V2" &&
context has amount_token_scaled &&
context.amount_token_scaled <= 2500000 &&
context.max_fee_atomic <= 100000
};
sign_policy() {
local author_id=$1
local resource_type=$2
local resource_name=$3
local action=$4
local cedar_doc=$5
python3 -c 'import sys; rt, rn, act, doc = sys.argv[1:]; v = lambda n: bytes([n & 127 | 128]) + v(n >> 7) if n > 127 else bytes([n]); f = lambda k, b: bytes([k << 3 | 2]) + v(len(b)) + b; open("policy_payload.bin", "wb").write(f(1 if rt == "instance" else 2, rn.encode()) + bytes([24, 1 if act == "ACTIVATE" else 2]) + f(4, f(1, doc.encode())))' "${resource_type}" "${resource_name}" "${action}" "${cedar_doc}"
openssl dgst -sha256 -sign "${author_id}_priv.pem" -out policy_sig.bin policy_payload.bin
openssl base64 -A -in policy_sig.bin
}
export CEDAR_RULE='permit (principal, action, resource) when { context.schema == "BITCOIN_TX_V2" && context has amount_token_scaled && context.amount_token_scaled <= 2500000 && context.max_fee_atomic <= 100000 };'
# Sign as Policy Author 1
QUORUM_SIGNATURE=$(sign_policy "author-dave" "instance" "${INSTANCE_NAME}" "ACTIVATE" "${CEDAR_RULE}")
POLICY_PAYLOAD_B64=$(openssl base64 -A -in policy_payload.bin)
POLICY_RESPONSE=$(jq -n \
--arg rule "$CEDAR_RULE" \
--arg sig "$QUORUM_SIGNATURE" \
--arg payload "$POLICY_PAYLOAD_B64" \
'{
transactionPolicy: {
displayName: "Bitcoin PSBT Permit Policy",
rule: {cedarDocument: $rule}
},
authorizationInfo: {
signature: $sig,
signedPayload: $payload
},
requestId: "11111111-1111-4111-8111-111111111202"
}' \
| curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "dave@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d @- \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/transactionPolicies")
echo "$POLICY_RESPONSE" | jq
export POLICY_NAME=$(echo "$POLICY_RESPONSE" | jq -r '.name')
B. Approve Policy (Transition to ACTIVE)
Policy changes require 2-of-2 signatures, and therefore a second Policy Author must call transactionPolicies.approve (ApproveTransactionPolicy) with a signed TransactionPolicySigningPayload.
Ethereum
# 1. Check the policy state
POLICY_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth print-access-token "eve@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${POLICY_NAME}")
POLICY_STATE=$(echo "$POLICY_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$POLICY_STATE" == "PENDING_ACTIVE" ]; then
# 2. Sign as Policy Author 2 naming transaction_policy ($POLICY_NAME)
QUORUM_SIGNATURE_2=$(sign_policy "author-eve" "transaction_policy" "${POLICY_NAME}" "ACTIVATE" "${CEDAR_RULE}")
POLICY_PAYLOAD_2_B64=$(openssl base64 -A -in policy_payload.bin)
# 3. Call ApproveTransactionPolicy as Policy Author 2
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "eve@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"authorizationInfo\": {
\"signature\": \"${QUORUM_SIGNATURE_2}\",
\"signedPayload\": \"${POLICY_PAYLOAD_2_B64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${POLICY_NAME}:approve" | jq
elif [ "$POLICY_STATE" == "ACTIVE" ]; then
echo "Policy '${POLICY_NAME}' is already ACTIVE. Skipping approval."
else
echo "Policy '${POLICY_NAME}' is in an unexpected state: $POLICY_STATE"
fi
Bitcoin
# 1. Check the policy state
POLICY_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth print-access-token "eve@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${POLICY_NAME}")
POLICY_STATE=$(echo "$POLICY_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$POLICY_STATE" == "PENDING_ACTIVE" ]; then
# 2. Sign as Policy Author 2 naming transaction_policy ($POLICY_NAME)
QUORUM_SIGNATURE_2=$(sign_policy "author-eve" "transaction_policy" "${POLICY_NAME}" "ACTIVATE" "${CEDAR_RULE}")
POLICY_PAYLOAD_2_B64=$(openssl base64 -A -in policy_payload.bin)
# 3. Call ApproveTransactionPolicy as Policy Author 2
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "eve@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"authorizationInfo\": {
\"signature\": \"${QUORUM_SIGNATURE_2}\",
\"signedPayload\": \"${POLICY_PAYLOAD_2_B64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${POLICY_NAME}:approve" | jq
elif [ "$POLICY_STATE" == "ACTIVE" ]; then
echo "Policy '${POLICY_NAME}' is already ACTIVE. Skipping approval."
else
echo "Policy '${POLICY_NAME}' is in an unexpected state: $POLICY_STATE"
fi
C. Create and Manage a KeyList (Optional)
A KeyList (KeyListService) is a named list of blockchain addresses (manualEntry, MPC key, or keyRing) that policies can reference as trusted destinations. Admins (ADMIN) can create a KeyList with keyLists.create and update its entries at any time with keyLists.addEntries and keyLists.removeEntries:
curl
KEY_LIST_RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Approved Destinations",
"description": "Trusted recipient addresses",
"entries": [
{
"manualEntry": {
"address": "0x1111111111111111111111111111111111111111",
"source": "Treasury onboarding",
"reason": "Approved recipient address"
}
}
]
}' \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/keyLists?requestId=11111111-1111-4111-8111-111111111301")
echo "$KEY_LIST_RESPONSE" | jq
export KEY_LIST_NAME=$(echo "$KEY_LIST_RESPONSE" | jq -r '.name')
Step 5: Create a Key
As Admin 1 (admin-1@example.com), create a KeyRing with an inline KeyPolicy and generate a distributed MPC key.
A. Create KeyRing with an inline KeyPolicy
A KeyRing is a logical grouping for your MPC keys and defines an immutable inline KeyPolicy (keyRing.keyPolicy) that every key created in the KeyRing follows. A KeyPolicy is a tree of threshold rules (PolicyNode) whose child subKeyShares can be teamNode (threshold of totalMembers in a Team), userNode (a specific SIGNER User), or a nested policyNode.
Here we configure a 2-of-3 threshold rule over the three signers in ${TEAM_NAME} (threshold: 2, totalMembers: 3, stored in GCP_TEE):
curl
KEY_RING_RESPONSE=$(jq -n \
--arg team "${TEAM_NAME}" \
'{
displayName: "Treasury KeyRing",
keyPolicy: {
policy: {
policyNodeId: "treasury-2-of-3",
threshold: 1,
subKeyShares: [
{
teamNode: {
team: $team,
threshold: 2,
totalMembers: 3,
storageInfo: {storageType: "GCP_TEE"}
}
}
]
}
}
}' \
| curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d @- \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/keyRings?requestId=11111111-1111-4111-8111-111111111401")
echo "$KEY_RING_RESPONSE" | jq
export KEY_RING_NAME=$(echo "$KEY_RING_RESPONSE" | jq -r '.name')
echo "Created KeyRing: $KEY_RING_NAME"
B. Generate Distributed Key (DKG)
Call keys.create (CreateKey) as Admin 1 to run Distributed Key Generation (DKG) across the participants selected by your KeyRing's KeyPolicy.
CreateKey is synchronous and returns the ACTIVE Key resource, including publicKeyInfo.publicKey and server-derived publicKeyInfo.blockchainAddresses (EC_SIGN_SECP256K1 for Ethereum and Bitcoin, or EC_SIGN_ED25519 for Solana once it is supported):
curl
KEY_RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d '{
"algorithm": "EC_SIGN_SECP256K1"
}' \
"${INSTANCE_URL}/v1beta/${KEY_RING_NAME}/keys?requestId=11111111-1111-4111-8111-111111111501")
echo "$KEY_RESPONSE" | jq
export KEY_NAME=$(echo "$KEY_RESPONSE" | jq -r '.name')
export PUBLIC_KEY_B64=$(echo "$KEY_RESPONSE" | jq -r '.publicKeyInfo.publicKey')
C. Derive Blockchain Address & Locate UTXOs
When CreateKey completes, publicKeyInfo.blockchainAddresses in the response automatically includes derived addresses (secp256k1Addresses.ethereumAddress and secp256k1Addresses.bitcoinMainnetAddresses for EC_SIGN_SECP256K1, or ed25519Addresses.solanaAddress for EC_SIGN_ED25519). You can also independently verify or derive Testnet addresses and locking scripts from the 65-byte uncompressed public key (0x04 || X || Y) using the following scripts, and locate any unspent funds needed for transactions.
Ethereum
Ethereum addresses are returned in .publicKeyInfo.blockchainAddresses.secp256k1Addresses.ethereumAddress and can also be 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 publicKey from Step 5.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 7, 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 Mainnet addresses (p2wpkhAddress, p2shP2wpkhAddress, and p2pkhAddress) are returned in .publicKeyInfo.blockchainAddresses.secp256k1Addresses.bitcoinMainnetAddresses. From the uncompressed 65-byte secp256k1 EC public key (0x04 || X || Y), you can also derive the 33-byte compressed public key, locking scripts, and Testnet4 addresses across all three formats:
- 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 5.B (publicKeyInfo.publicKey, 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 6:
- 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 6:
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 6: Execute an MPC Operation
Policy evaluation and signing initiation happen together when a SIGNER calls transactions.create (CreateTransaction). The instance decodes payload according to schema, evaluates all active transaction policies, records the result in policyEvaluationRecord, and records the initiating signer's AUTHORIZE decision.
A. Create Transaction (Evaluate Policy & Initiate Signing)
Submit the transaction as Signer 1 (alice@example.com) for policy evaluation and signing:
Ethereum, KMS
🛑 Case A: Rejection (Too High Value)
Attempt to create a transaction of 3 ETH (exceeds our 2 ETH policy limit):
sign_tx_decision() {
local signer_id=$1
local resource_type=$2
local resource_name=$3
local decision=$4
local digest_hex=$5
python3 -c 'import sys; rt, rn, dec, dg = sys.argv[1:]; f = lambda k, b: bytes([k << 3 | 2, len(b)]) + b; open("tx_decision_payload.bin", "wb").write(f(1 if rt == "key" else 2, rn.encode()) + bytes([24, 1 if dec == "AUTHORIZE" else 2]) + f(4, bytes.fromhex(dg)))' "${resource_type}" "${resource_name}" "${decision}" "${digest_hex}"
gcloud kms asymmetric-sign \
--version=1 \
--key="${signer_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=tx_decision_payload.bin \
--signature-file=tx_decision_sig.bin > /dev/null
openssl base64 -A -in tx_decision_sig.bin
}
# Payload for 3 ETH (amount_token_scaled = 3,000,000,000 > 2,000,000,000)
RAW_TX_BYTES_HEX="ef808504a817c8008252089411111111111111111111111111111111111111118829a2241af62c00008083aa36a78080"
PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$RAW_TX_BYTES_HEX')).decode('utf-8'))")
DIGEST_HEX=$(python3 -c "from eth_utils import keccak; print(keccak(bytes.fromhex('$RAW_TX_BYTES_HEX')).hex())")
DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"ETHEREUM_TX_V2\",
\"payload\": \"${PAYLOAD_B64}\",
\"digest\": \"${DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111601\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions" | jq
Expected Policy Evaluation Result: decision: DENY (denyReason: NO_PERMIT_RULE_MATCHED).
✅ Case B: Approval (Permitted Value)
Create a transaction of 1 Gwei (within our 2 ETH limit):
# Payload for 1 Gwei
RAW_TX_BYTES_HEX="eb808504a817c800825208941111111111111111111111111111111111111111843b9aca008083aa36a78080"
PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$RAW_TX_BYTES_HEX')).decode('utf-8'))")
export DIGEST_HEX=$(python3 -c "from eth_utils import keccak; print(keccak(bytes.fromhex('$RAW_TX_BYTES_HEX')).hex())")
export DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"ETHEREUM_TX_V2\",
\"payload\": \"${PAYLOAD_B64}\",
\"digest\": \"${DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111602\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions")
echo "$RESPONSE" | jq
export TRANSACTION_NAME=$(echo "$RESPONSE" | jq -r '.name')
Expected Response: state: PENDING_AUTHORIZATION and policyEvaluationRecord.decision: PERMIT.
Ethereum, OpenSSL
🛑 Case A: Rejection (Too High Value)
Attempt to create a transaction of 3 ETH (exceeds our 2 ETH policy limit):
sign_tx_decision() {
local signer_id=$1
local resource_type=$2
local resource_name=$3
local decision=$4
local digest_hex=$5
python3 -c 'import sys; rt, rn, dec, dg = sys.argv[1:]; f = lambda k, b: bytes([k << 3 | 2, len(b)]) + b; open("tx_decision_payload.bin", "wb").write(f(1 if rt == "key" else 2, rn.encode()) + bytes([24, 1 if dec == "AUTHORIZE" else 2]) + f(4, bytes.fromhex(dg)))' "${resource_type}" "${resource_name}" "${decision}" "${digest_hex}"
openssl dgst -sha256 -sign "${signer_id}_priv.pem" -out tx_decision_sig.bin tx_decision_payload.bin
openssl base64 -A -in tx_decision_sig.bin
}
# Payload for 3 ETH (amount_token_scaled = 3,000,000,000 > 2,000,000,000)
RAW_TX_BYTES_HEX="ef808504a817c8008252089411111111111111111111111111111111111111118829a2241af62c00008083aa36a78080"
PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$RAW_TX_BYTES_HEX')).decode('utf-8'))")
DIGEST_HEX=$(python3 -c "from eth_utils import keccak; print(keccak(bytes.fromhex('$RAW_TX_BYTES_HEX')).hex())")
DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"ETHEREUM_TX_V2\",
\"payload\": \"${PAYLOAD_B64}\",
\"digest\": \"${DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111601\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions" | jq
Expected Policy Evaluation Result: decision: DENY (denyReason: NO_PERMIT_RULE_MATCHED).
✅ Case B: Approval (Permitted Value)
Create a transaction of 1 Gwei (within our 2 ETH limit):
# Payload for 1 Gwei
RAW_TX_BYTES_HEX="eb808504a817c800825208941111111111111111111111111111111111111111843b9aca008083aa36a78080"
PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$RAW_TX_BYTES_HEX')).decode('utf-8'))")
export DIGEST_HEX=$(python3 -c "from eth_utils import keccak; print(keccak(bytes.fromhex('$RAW_TX_BYTES_HEX')).hex())")
export DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"ETHEREUM_TX_V2\",
\"payload\": \"${PAYLOAD_B64}\",
\"digest\": \"${DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111602\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions")
echo "$RESPONSE" | jq
export TRANSACTION_NAME=$(echo "$RESPONSE" | jq -r '.name')
Expected Response: state: PENDING_AUTHORIZATION and policyEvaluationRecord.decision: PERMIT.
Bitcoin, KMS
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.
Bitcoin policy evaluation 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 5.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 create 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 policy evaluation decision.
sign_tx_decision() {
local signer_id=$1
local resource_type=$2
local resource_name=$3
local decision=$4
local digest_hex=$5
python3 -c 'import sys; rt, rn, dec, dg = sys.argv[1:]; f = lambda k, b: bytes([k << 3 | 2, len(b)]) + b; open("tx_decision_payload.bin", "wb").write(f(1 if rt == "key" else 2, rn.encode()) + bytes([24, 1 if dec == "AUTHORIZE" else 2]) + f(4, bytes.fromhex(dg)))' "${resource_type}" "${resource_name}" "${decision}" "${digest_hex}"
gcloud kms asymmetric-sign \
--version=1 \
--key="${signer_id}-key" \
--keyring="mpc-assent-keys" \
--location="us-central1" \
--digest-algorithm=sha256 \
--input-file=tx_decision_payload.bin \
--signature-file=tx_decision_sig.bin > /dev/null
openssl base64 -A -in tx_decision_sig.bin
}
# Construct a PSBT payload with miner_fee_sats = 15000 -> max_fee_atomic = 150000 (exceeds 100000 limit)
REJECTED_PSBT_HEX="70736274ff0100710200000001d81639a6635e3921442629bc7f51f22ca9e4ca3d27c280c9f1936915334f598f0100000000ffffffff0250c3000000000000160014751e76e8199196d454941c45d1b3a323f1433bd6580f020000000000160014eced05cd51d964102a20c856b09080ad620e9da2000000000001011f400d030000000000160014eced05cd51d964102a20c856b09080ad620e9da2000000"
REJECTED_PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$REJECTED_PSBT_HEX')).decode('utf-8'))")
REJECTED_DIGEST_HEX=$(python3 -c "from embit import script; from embit.psbt import PSBT; p=PSBT.parse(bytes.fromhex('$REJECTED_PSBT_HEX')); u=p.inputs[0].witness_utxo; sc=script.Script(b'\x76\xa9\x14'+u.script_pubkey.data[2:]+b'\x88\xac'); print(p.tx.sighash_segwit(0, sc, u.value).hex())")
REJECTED_DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$REJECTED_DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${REJECTED_DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"BITCOIN_TX_V2\",
\"payload\": \"${REJECTED_PAYLOAD_B64}\",
\"digest\": \"${REJECTED_DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111603\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions" | jq
Expected Policy Evaluation Result: decision: DENY (denyReason: NO_PERMIT_RULE_MATCHED).
✅ Case B: Approval (Permitted Bitcoin PSBT)
Create a transaction with the previously constructed valid PSBT (RECIPIENT_SATS = 50000 maps to amount_token_scaled = 500000 <= 2500000 and MINER_FEE_SATS = 250 maps to max_fee_atomic = 2500 <= 100000):
PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$BTC_PSBT_HEX')).decode('utf-8'))")
export DIGEST_HEX=$(python3 -c "from embit import script; from embit.psbt import PSBT; p=PSBT.parse(bytes.fromhex('$BTC_PSBT_HEX')); u=p.inputs[0].witness_utxo; sc=script.Script(b'\x76\xa9\x14'+u.script_pubkey.data[2:]+b'\x88\xac'); print(p.tx.sighash_segwit(0, sc, u.value).hex())")
export DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"BITCOIN_TX_V2\",
\"payload\": \"${PAYLOAD_B64}\",
\"digest\": \"${DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111604\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions")
echo "$RESPONSE" | jq
export TRANSACTION_NAME=$(echo "$RESPONSE" | jq -r '.name')
Expected Response: state: PENDING_AUTHORIZATION and policyEvaluationRecord.decision: PERMIT.
Bitcoin, OpenSSL
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.
Bitcoin policy evaluation 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 5.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 create 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 policy evaluation decision.
sign_tx_decision() {
local signer_id=$1
local resource_type=$2
local resource_name=$3
local decision=$4
local digest_hex=$5
python3 -c 'import sys; rt, rn, dec, dg = sys.argv[1:]; f = lambda k, b: bytes([k << 3 | 2, len(b)]) + b; open("tx_decision_payload.bin", "wb").write(f(1 if rt == "key" else 2, rn.encode()) + bytes([24, 1 if dec == "AUTHORIZE" else 2]) + f(4, bytes.fromhex(dg)))' "${resource_type}" "${resource_name}" "${decision}" "${digest_hex}"
openssl dgst -sha256 -sign "${signer_id}_priv.pem" -out tx_decision_sig.bin tx_decision_payload.bin
openssl base64 -A -in tx_decision_sig.bin
}
# Construct a PSBT payload with miner_fee_sats = 15000 -> max_fee_atomic = 150000 (exceeds 100000 limit)
REJECTED_PSBT_HEX="70736274ff0100710200000001d81639a6635e3921442629bc7f51f22ca9e4ca3d27c280c9f1936915334f598f0100000000ffffffff0250c3000000000000160014751e76e8199196d454941c45d1b3a323f1433bd6580f020000000000160014eced05cd51d964102a20c856b09080ad620e9da2000000000001011f400d030000000000160014eced05cd51d964102a20c856b09080ad620e9da2000000"
REJECTED_PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$REJECTED_PSBT_HEX')).decode('utf-8'))")
REJECTED_DIGEST_HEX=$(python3 -c "from embit import script; from embit.psbt import PSBT; p=PSBT.parse(bytes.fromhex('$REJECTED_PSBT_HEX')); u=p.inputs[0].witness_utxo; sc=script.Script(b'\x76\xa9\x14'+u.script_pubkey.data[2:]+b'\x88\xac'); print(p.tx.sighash_segwit(0, sc, u.value).hex())")
REJECTED_DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$REJECTED_DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${REJECTED_DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"BITCOIN_TX_V2\",
\"payload\": \"${REJECTED_PAYLOAD_B64}\",
\"digest\": \"${REJECTED_DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111603\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions" | jq
Expected Policy Evaluation Result: decision: DENY (denyReason: NO_PERMIT_RULE_MATCHED).
✅ Case B: Approval (Permitted Bitcoin PSBT)
Create a transaction with the previously constructed valid PSBT (RECIPIENT_SATS = 50000 maps to amount_token_scaled = 500000 <= 2500000 and MINER_FEE_SATS = 250 maps to max_fee_atomic = 2500 <= 100000):
PAYLOAD_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$BTC_PSBT_HEX')).decode('utf-8'))")
export DIGEST_HEX=$(python3 -c "from embit import script; from embit.psbt import PSBT; p=PSBT.parse(bytes.fromhex('$BTC_PSBT_HEX')); u=p.inputs[0].witness_utxo; sc=script.Script(b'\x76\xa9\x14'+u.script_pubkey.data[2:]+b'\x88\xac'); print(p.tx.sighash_segwit(0, sc, u.value).hex())")
export DIGEST_B64=$(python3 -c "import base64; print(base64.b64encode(bytes.fromhex('$DIGEST_HEX')).decode('utf-8'))")
SIGNER_1_SIG_B64=$(sign_tx_decision "signer-alice" "key" "${KEY_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNED_DECISION_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"transaction\": {
\"action\": \"Action::\\\"Transfer\\\"\",
\"schema\": \"BITCOIN_TX_V2\",
\"payload\": \"${PAYLOAD_B64}\",
\"digest\": \"${DIGEST_B64}\"
},
\"authorizationInfo\": {
\"signature\": \"${SIGNER_1_SIG_B64}\",
\"signedPayload\": \"${SIGNED_DECISION_PAYLOAD_B64}\"
},
\"requestId\": \"11111111-1111-4111-8111-111111111604\"
}" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions")
echo "$RESPONSE" | jq
export TRANSACTION_NAME=$(echo "$RESPONSE" | jq -r '.name')
Expected Response: state: PENDING_AUTHORIZATION and policyEvaluationRecord.decision: PERMIT.
🕵️♂️ Check Transaction State (Polling)
Poll the transaction status by running the following command periodically:
curl
curl -s -X GET \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${TRANSACTION_NAME}" | jq
B. Record Signer Decision (Authorize)
Because CreateTransaction in Step 6.A already recorded Signer 1's AUTHORIZE decision (1 of 2 required by the 2-of-3 KeyPolicy threshold), only one additional co-signer holding a key share needs to authorize the transaction using transactions.recordSignerDecision (RecordSignerDecision).
Sign the TransactionSigningPayload naming transaction (${TRANSACTION_NAME}) and call recordSignerDecision as Signer 2 (bob@example.com):
curl
# 1. Check state for Signer 2
TX_INFO=$(curl -s -H "Authorization: Bearer $(gcloud auth print-access-token "bob@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${TRANSACTION_NAME}")
TX_STATE=$(echo "$TX_INFO" | jq -r '.state // "UNKNOWN"')
if [ "$TX_STATE" == "PENDING_AUTHORIZATION" ]; then
# 2. Sign as Signer 2 naming transaction ($TRANSACTION_NAME)
SIGNER_2_SIG_B64=$(sign_tx_decision "signer-bob" "transaction" "${TRANSACTION_NAME}" "AUTHORIZE" "${DIGEST_HEX}")
SIGNER_2_PAYLOAD_B64=$(openssl base64 -A -in tx_decision_payload.bin)
# 3. Call RecordSignerDecision as Signer 2
echo "Recording AUTHORIZE decision for Signer 2..."
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token "bob@example.com")" \
-H "X-Goog-User-Project: my-project" \
-H "Content-Type: application/json" \
-d "{
\"decision\": \"AUTHORIZE\",
\"authorizationInfo\": {
\"signature\": \"${SIGNER_2_SIG_B64}\",
\"signedPayload\": \"${SIGNER_2_PAYLOAD_B64}\"
}
}" \
"${INSTANCE_URL}/v1beta/${TRANSACTION_NAME}:recordSignerDecision" | jq
elif [ "$TX_STATE" == "SIGNING" ] || [ "$TX_STATE" == "SIGNING_SUCCEEDED" ]; then
echo "Transaction is in state $TX_STATE. Skipping Signer 2."
else
echo "Transaction is in an unexpected state: $TX_STATE"
fi
C. Fetch Final Signature
Once the threshold of authorizations is reached, check the transaction state and fetch finalSignature (and recoveryId for EC_SIGN_SECP256K1 keys):
curl
curl -s -X GET \
-H "Authorization: Bearer $(gcloud auth print-access-token "alice@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${TRANSACTION_NAME}" | jq
When state is SIGNING_SUCCEEDED, copy the finalSignature field from the returned JSON for Step 7.
Step 7: Broadcast Transaction to Blockchain
Once your transaction reaches the SIGNING_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 in finalSignature (comprising the 32-byte r and s values in canonical low-S form per EIP-2), along with the recovery parity bit (0 or 1) in recoveryId.
The following Python script constructs the signed transaction using your finalSignature, verifies the recovered sender address against your MPC key's Ethereum address, 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 6.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 commands to inspect and manage the state of your resources at any time.
🏢 1. Instances
Inspect and manage MPC instances in your project and location.
A. List all Instances
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"https://mpc.googleapis.com/v1beta/projects/my-project/locations/us-central1/instances" | jq
B. Get details for a specific Instance
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"https://mpc.googleapis.com/v1beta/${INSTANCE_NAME}" | jq
👥 2. Teams & Users
Inspect teams and users in your instance.
A. List all Teams
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/teams" | jq
B. Get details for a specific Team
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${TEAM_NAME}" | jq
C. List all registered Users
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/users" | jq
D. Get details for a specific User
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${SIGNER_1_NAME}" | jq
📜 3. Transaction Policies & KeyLists
Inspect transaction policies and key lists.
A. List all Transaction Policies
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/transactionPolicies" | jq
B. Get details for a specific Transaction Policy
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${POLICY_NAME}" | jq
C. List and Get KeyLists
curl
# List all KeyLists
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/keyLists" | jq
# Get a specific KeyList
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${KEY_LIST_NAME}" | jq
🔑 4. Keys & KeyRings
Inspect key rings and distributed keys.
A. List all KeyRings
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/keyRings" | jq
B. List all Keys in a KeyRing
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${KEY_RING_NAME}/keys" | jq
C. Get details of a specific Distributed Key
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}" | jq
📝 5. Transactions (Signing Operations)
Inspect transactions and signing status.
A. List all Transactions for a Key (or across all Keys)
curl
# List transactions for a specific Key
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${KEY_NAME}/transactions" | jq
# List transactions across all KeyRings and Keys using the '-' wildcard
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${INSTANCE_NAME}/keyRings/-/keys/-/transactions" | jq
B. Get details or status of a specific Transaction
curl
curl -H "Authorization: Bearer $(gcloud auth print-access-token "admin-1@example.com")" \
-H "X-Goog-User-Project: my-project" \
"${INSTANCE_URL}/v1beta/${TRANSACTION_NAME}" | 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.