id: bitcoin-multisig-psbt-cosigning
namespace: company.team
description: |
Spend from a multisig treasury without any private key on the server.
A spend request is checked against your policy, a PSBT is built from a
watch-only wallet, and the flow waits for signers to return signed copies.
Every upload is verified before it counts, and the transaction is broadcast
once enough signatures are in. Coins are released if the request is
rejected, expires, or fails.
triggers:
- id: spend_request
type: io.kestra.plugin.core.trigger.Webhook
description: 'Your treasury app or an internal form starts a spend request by
POSTing {"to_address": "...", "amount_btc": 0.1, "memo": "...",
"requested_by": "..."} to this webhook.'
key: treasury-spend-change-me
inputs:
- id: to_address
type: STRING
required: false
description: Destination address for manual runs. Webhook runs take it from the
request body.
- id: amount_btc
type: FLOAT
required: false
description: Amount to send, in BTC, for manual runs.
- id: memo
type: STRING
required: false
description: Why the money is moving. Shown to every signer.
- id: requested_by
type: STRING
required: false
description: Who asked for the spend.
- id: rpc_url
type: STRING
defaults: http://127.0.0.1:38332
description: Bitcoin Core JSON-RPC endpoint. The default is the signet port; use
18443 for regtest and 8332 for mainnet.
- id: wallet
type: STRING
defaults: treasury
description: Watch-only Bitcoin Core wallet that holds the multisig descriptor.
It has no private keys.
- id: signers
type: JSON
defaults: |
{"f0783cf6": "Alice", "62b9a7c9": "Bob", "6a03cf93": "Carol"}
description: Master key fingerprint of each signer and the name to show. The
flow recognizes who signed from the signature itself.
- id: max_spend_btc
type: FLOAT
defaults: 0.5
description: Requests above this amount are rejected before a PSBT is built.
- id: fee_conf_target
type: INT
defaults: 6
description: Confirmation target in blocks used by Bitcoin Core's fee estimation.
- id: max_fee_rate
type: FLOAT
defaults: 50
description: Highest fee rate in sat/vB you are willing to pay. Above it the
request is deferred and the coins released.
- id: allow_mainnet
type: BOOL
defaults: false
description: Safety switch. The flow refuses to build transactions on mainnet
unless this is true.
- id: kestra_url
type: STRING
defaults: http://localhost:8080
description: Base URL of your Kestra UI, used for the links in Slack.
variables:
wallet_rpc: "{{ inputs.rpc_url }}/wallet/{{ inputs.wallet }}"
execution_link: "{{ inputs.kestra_url }}/ui/main/executions/{{ flow.namespace
}}/{{ flow.id }}/{{ execution.id }}"
tasks:
- id: node_check
type: io.kestra.plugin.core.http.Request
description: Fail fast when the node is unreachable or the credentials are
wrong, and learn which chain it is on.
uri: "{{ inputs.rpc_url }}"
method: POST
body: '{"jsonrpc": "1.0", "id": "kestra", "method": "getblockchaininfo",
"params": []}'
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT30S
maxAttempts: 3
- id: mainnet_guard
type: io.kestra.plugin.core.flow.If
condition: "{{ (outputs.node_check.body | jq('.result.chain') | first) == 'main'
and not inputs.allow_mainnet }}"
then:
- id: refuse_mainnet
type: io.kestra.plugin.core.execution.Fail
errorMessage: The node is on mainnet and allow_mainnet is false. Test on signet
first, then set allow_mainnet to true.
- id: ensure_table
type: io.kestra.plugin.jdbc.postgresql.Query
description: Create the spend request log on first run. Every request,
signature, and outcome is recorded here.
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
sql: |
CREATE TABLE IF NOT EXISTS spend_requests (
id BIGSERIAL PRIMARY KEY,
to_address TEXT,
amount_btc NUMERIC(16, 8),
memo TEXT,
requested_by TEXT,
status TEXT NOT NULL DEFAULT 'requested',
reason TEXT,
unsigned_txid TEXT,
psbt TEXT,
locked_inputs JSONB,
fee_btc NUMERIC(16, 8),
signed_by TEXT[] NOT NULL DEFAULT '{}',
txid TEXT,
execution_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
- id: record_request
type: io.kestra.plugin.jdbc.postgresql.Query
description: Log the request. Values from the webhook body are bound as query
parameters, never pasted into SQL.
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: FETCH_ONE
parameters:
to_address: "{{ trigger.body.to_address ?? inputs.to_address ?? '' }}"
amount: "{{ trigger.body.amount_btc ?? inputs.amount_btc ?? 0 }}"
memo: "{{ trigger.body.memo ?? inputs.memo ?? '' }}"
requested_by: "{{ trigger.body.requested_by ?? inputs.requested_by ?? 'unknown' }}"
execution_id: "{{ execution.id }}"
sql: |
INSERT INTO spend_requests (to_address, amount_btc, memo, requested_by, execution_id)
VALUES (:to_address, CAST(:amount AS NUMERIC), :memo, :requested_by, :execution_id)
RETURNING id, to_address, amount_btc, CAST(amount_btc AS TEXT) AS amount, memo, requested_by
- id: validate_address
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'validateaddress',
'params': [outputs.record_request.row.to_address]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT30S
maxAttempts: 3
- id: policy_check
type: io.kestra.plugin.core.flow.If
description: Reject requests that break the treasury policy before any coins are
reserved.
condition: "{{ not (outputs.validate_address.body | jq('.result.isvalid') |
first) or outputs.record_request.row.amount_btc <= 0 or
outputs.record_request.row.amount_btc > inputs.max_spend_btc }}"
then:
- id: mark_policy_rejected
type: io.kestra.plugin.jdbc.postgresql.Query
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: FETCH_ONE
parameters:
id: "{{ outputs.record_request.row.id }}"
max: "{{ inputs.max_spend_btc }}"
address_valid: "{{ outputs.validate_address.body | jq('.result.isvalid') | first }}"
sql: |
UPDATE spend_requests SET status = 'rejected', updated_at = now(),
reason = CASE
WHEN CAST(:address_valid AS BOOLEAN) IS NOT TRUE THEN 'invalid address for this network'
WHEN amount_btc IS NULL OR amount_btc <= 0 THEN 'amount must be positive'
ELSE 'amount is above the ' || :max || ' BTC policy limit'
END
WHERE id = CAST(:id AS BIGINT)
RETURNING id, reason
- id: notify_policy_rejected
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "Treasury spend #{{ outputs.mark_policy_rejected.row.id }} from {{
outputs.record_request.row.requested_by }} was rejected by policy: {{
outputs.mark_policy_rejected.row.reason }}. No coins were reserved."
- id: stop_rejected
type: io.kestra.plugin.core.execution.Exit
state: WARNING
- id: create_psbt
type: io.kestra.plugin.core.http.Request
description: Build an unsigned PSBT from the watch-only wallet. lockUnspents
reserves the selected coins so a parallel request cannot spend them too.
uri: "{{ render(vars.wallet_rpc) }}"
method: POST
body: >-
{"jsonrpc": "1.0", "id": "kestra", "method": "walletcreatefundedpsbt",
"params": [[], [{ {{ outputs.record_request.row.to_address | toJson }}: {{
outputs.record_request.row.amount_btc }} }], 0, {"lockUnspents": true,
"replaceable": true, "conf_target": {{ inputs.fee_conf_target }} }]}
options:
allowFailed: true
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: psbt_built
type: io.kestra.plugin.core.flow.If
description: Turn a wallet error (for example insufficient funds or failed fee
estimation) into a clear failure message.
condition: "{{ (outputs.create_psbt.body | jq('.error.message // \"\"') | first)
!= '' }}"
then:
- id: cannot_build
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Bitcoin Core could not build the transaction: {{
outputs.create_psbt.body | jq('.error.message') | first }}"
- id: decode_psbt
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'decodepsbt', 'params':
[outputs.create_psbt.body | jq('.result.psbt') | first]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: analyze_psbt
type: io.kestra.plugin.core.http.Request
description: Estimate the final size and fee rate of the signed transaction.
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'analyzepsbt', 'params':
[outputs.create_psbt.body | jq('.result.psbt') | first]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: save_psbt
type: io.kestra.plugin.jdbc.postgresql.Query
description: Store the PSBT, its transaction id, and the reserved coins.
Signatures are only accepted for this exact transaction.
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: FETCH_ONE
parameters:
id: "{{ outputs.record_request.row.id }}"
psbt: "{{ outputs.create_psbt.body | jq('.result.psbt') | first }}"
fee: "{{ outputs.create_psbt.body | jq('.result.fee') | first }}"
decoded: "{{ outputs.decode_psbt.body }}"
fee_rate: "{{ (outputs.analyze_psbt.body | jq('.result.estimated_feerate') |
first) * 100000 }}"
sql: |
WITH d AS (SELECT CAST(:decoded AS JSONB) -> 'result' AS r)
UPDATE spend_requests SET
status = 'awaiting_signatures',
psbt = :psbt,
fee_btc = CAST(:fee AS NUMERIC),
unsigned_txid = d.r -> 'tx' ->> 'txid',
locked_inputs = (SELECT jsonb_agg(jsonb_build_object('txid', v ->> 'txid', 'vout', CAST(v ->> 'vout' AS INT)))
FROM jsonb_array_elements(d.r -> 'tx' -> 'vin') v),
updated_at = now()
FROM d
WHERE id = CAST(:id AS BIGINT)
RETURNING id, unsigned_txid, CAST(fee_btc AS TEXT) AS fee_btc, CAST(locked_inputs AS TEXT) AS locked_inputs,
round(CAST(:fee_rate AS NUMERIC), 1) AS fee_rate_sat_vb,
split_part(d.r -> 'inputs' -> 0 -> 'witness_script' ->> 'asm', ' ', 1) AS required_signatures
- id: fee_guard
type: io.kestra.plugin.core.flow.If
description: Defer the spend when fees are above your limit, and release the
coins it reserved.
condition: "{{ outputs.save_psbt.row.fee_rate_sat_vb > inputs.max_fee_rate }}"
then:
- id: release_deferred
type: io.kestra.plugin.core.http.Request
uri: "{{ render(vars.wallet_rpc) }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'lockunspent', 'params':
[true, fromJson(outputs.save_psbt.row.locked_inputs)]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: mark_deferred
type: io.kestra.plugin.jdbc.postgresql.Query
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
parameters:
id: "{{ outputs.record_request.row.id }}"
reason: "fee rate {{ outputs.save_psbt.row.fee_rate_sat_vb }} sat/vB is above
the {{ inputs.max_fee_rate }} sat/vB limit"
sql: UPDATE spend_requests SET status = 'deferred', reason = :reason, updated_at
= now() WHERE id = CAST(:id AS BIGINT)
- id: notify_deferred
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "Treasury spend #{{ outputs.record_request.row.id }} deferred: fee
rate {{ outputs.save_psbt.row.fee_rate_sat_vb }} sat/vB is above the
{{ inputs.max_fee_rate }} sat/vB limit. Coins released; submit the
request again when fees drop."
- id: stop_deferred
type: io.kestra.plugin.core.execution.Exit
state: WARNING
- id: unsigned_psbt_file
type: io.kestra.plugin.core.storage.Write
description: The unsigned PSBT as a file, downloadable from the execution's
Outputs tab and loadable in Sparrow, Electrum, or a hardware wallet
companion app.
content: "{{ outputs.create_psbt.body | jq('.result.psbt') | first }}"
extension: .psbt
- id: ask_signers
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
Treasury spend #{{ outputs.record_request.row.id }} needs {{ outputs.save_psbt.row.required_signatures }} signatures.
{{ outputs.record_request.row.amount }} BTC to {{ outputs.record_request.row.to_address }}, requested by {{ outputs.record_request.row.requested_by }}{% if outputs.record_request.row.memo != '' %}: "{{ outputs.record_request.row.memo }}"{% endif %}
Fee {{ outputs.save_psbt.row.fee_btc }} BTC ({{ outputs.save_psbt.row.fee_rate_sat_vb }} sat/vB). Transaction {{ outputs.save_psbt.row.unsigned_txid }}.
Check the amount and address on your signing device, sign the PSBT, then paste the signed PSBT (base64) or reject the spend here: {{ render(vars.execution_link) }}
Unsigned PSBT: {{ outputs.create_psbt.body | jq('.result.psbt') | first }}
- id: collect_signatures
type: io.kestra.plugin.core.flow.LoopUntil
description: Wait for signed PSBTs one at a time until the transaction is fully
signed. A bad upload is reported and the flow keeps waiting.
condition: "{{ outputs.record_signature.row.complete ?? false }}"
checkFrequency:
interval: PT1S
maxIterations: 20
failOnMaxReached: true
tasks:
- id: wait_for_signature
type: io.kestra.plugin.core.flow.Pause
description: Each signer resumes the execution with their signed PSBT. Unsigned
requests expire after 48 hours, which fails the run and releases the
coins.
pauseDuration: PT48H
behavior: FAIL
onResume:
- id: decision
type: SELECT
values:
- Sign
- Reject
defaults: Sign
description: Reject stops the spend for everyone and releases the coins.
- id: signed_psbt
type: STRING
required: false
description: Your signed PSBT, base64-encoded.
- id: note
type: STRING
required: false
description: Optional note, recorded with a rejection.
- id: signer_rejected
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.wait_for_signature.onResume.decision == 'Reject' }}"
then:
- id: release_rejected
type: io.kestra.plugin.core.http.Request
uri: "{{ render(vars.wallet_rpc) }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'lockunspent', 'params':
[true, fromJson(outputs.save_psbt.row.locked_inputs)]} | toJson
}}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: mark_rejected
type: io.kestra.plugin.jdbc.postgresql.Query
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
parameters:
id: "{{ outputs.record_request.row.id }}"
reason: "rejected by a signer: {{ outputs.wait_for_signature.onResume.note ??
'no reason given' }}"
sql: UPDATE spend_requests SET status = 'rejected', reason = :reason, updated_at
= now() WHERE id = CAST(:id AS BIGINT)
- id: notify_rejected
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "Treasury spend #{{ outputs.record_request.row.id }} was rejected
by a signer ({{ outputs.wait_for_signature.onResume.note ?? 'no
reason given' }}). Coins released, nothing was broadcast."
- id: stop_signer_rejected
type: io.kestra.plugin.core.execution.Exit
state: WARNING
- id: load_state
type: io.kestra.plugin.jdbc.postgresql.Query
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: FETCH_ONE
parameters:
id: "{{ outputs.record_request.row.id }}"
sql: SELECT psbt, signed_by FROM spend_requests WHERE id = CAST(:id AS BIGINT)
- id: decode_upload
type: io.kestra.plugin.core.http.Request
description: Decode the upload. allowFailed keeps a malformed paste from failing
the run, so it can be reported instead.
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'decodepsbt', 'params':
[outputs.wait_for_signature.onResume.signed_psbt ?? '']} | toJson }}"
options:
allowFailed: true
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: upload_matches
type: io.kestra.plugin.core.flow.If
description: Accept the upload only if it is a valid PSBT for exactly the
transaction that was proposed.
condition: "{{ (outputs.decode_upload.body | jq('.result.tx.txid // \"\"') |
first) == outputs.save_psbt.row.unsigned_txid }}"
then:
- id: combine
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'combinepsbt', 'params':
[[outputs.load_state.row.psbt,
outputs.wait_for_signature.onResume.signed_psbt]]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: decode_combined
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'decodepsbt', 'params':
[outputs.combine.body | jq('.result') | first]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: analyze_combined
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'analyzepsbt', 'params':
[outputs.combine.body | jq('.result') | first]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: record_signature
type: io.kestra.plugin.jdbc.postgresql.Query
description: Work out who has signed from the key fingerprints in the PSBT,
store the combined PSBT, and report whether it is complete. Some
wallets finalize the PSBT when they add the last signature, which
removes the per-key signatures; that signer is recorded as "last
signer (finalized PSBT)".
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: FETCH_ONE
parameters:
id: "{{ outputs.record_request.row.id }}"
psbt: "{{ outputs.combine.body | jq('.result') | first }}"
decoded: "{{ outputs.decode_combined.body }}"
signers: "{{ inputs.signers | toJson }}"
complete: "{{ ['finalizer', 'extractor'] contains (outputs.analyze_combined.body
| jq('.result.next') | first) }}"
sql: |
WITH d AS (SELECT CAST(:decoded AS JSONB) -> 'result' -> 'inputs' -> 0 AS i),
-- webhook runs pass JSON input defaults as a string, manual runs as an object
raw AS (SELECT CAST(:signers AS JSONB) AS j),
names AS (SELECT CASE WHEN jsonb_typeof(j) = 'string' THEN CAST(j #>> '{}' AS JSONB) ELSE j END AS m FROM raw),
signed AS (
SELECT coalesce(names.m ->> (k ->> 'master_fingerprint'), k ->> 'master_fingerprint') AS name
FROM d, names, jsonb_array_elements(d.i -> 'bip32_derivs') k
WHERE jsonb_exists(coalesce(d.i -> 'partial_signatures', '{}'), k ->> 'pubkey')
),
before AS (SELECT signed_by FROM spend_requests WHERE id = CAST(:id AS BIGINT)),
finalized AS (SELECT jsonb_exists(d.i, 'final_scriptwitness') AS yes FROM d)
UPDATE spend_requests s SET
psbt = :psbt,
signed_by = CASE WHEN f.yes
THEN ARRAY(SELECT unnest(b.signed_by) UNION SELECT 'last signer (finalized PSBT)' ORDER BY 1)
ELSE ARRAY(SELECT name FROM signed ORDER BY name) END,
updated_at = now()
FROM before b, finalized f
WHERE s.id = CAST(:id AS BIGINT)
RETURNING
array_to_string(s.signed_by, ', ') AS signed_by,
cardinality(s.signed_by) AS signatures,
array_to_string(ARRAY(SELECT unnest(s.signed_by) EXCEPT SELECT unnest(b.signed_by)), ', ') AS new_signers,
CAST(:complete AS BOOLEAN) AS complete,
s.psbt
- id: notify_progress
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: >-
{% if outputs.record_signature.row.new_signers == '' %}Treasury
spend #{{ outputs.record_request.row.id }}: the upload added no
new signature (already signed by {{
outputs.record_signature.row.signed_by }}). Still waiting.{% else
%}Treasury spend #{{ outputs.record_request.row.id }}: signature
from {{ outputs.record_signature.row.new_signers }} recorded ({{
outputs.record_signature.row.signatures }} of {{
outputs.save_psbt.row.required_signatures }}).{% if not
outputs.record_signature.row.complete %} Waiting for more signers:
{{ render(vars.execution_link) }}{% endif %}{% endif %}
else:
- id: notify_bad_upload
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: >-
Treasury spend #{{ outputs.record_request.row.id }}: an upload was
refused because {% if (outputs.decode_upload.body |
jq('.error.message // ""') | first) != '' %}it is not a valid PSBT
({{ outputs.decode_upload.body | jq('.error.message') | first
}}){% else %}it signs a different transaction ({{
outputs.decode_upload.body | jq('.result.tx.txid') | first }}
instead of {{ outputs.save_psbt.row.unsigned_txid }}){% endif %}.
Nothing was recorded; still waiting for signatures: {{
render(vars.execution_link) }}
- id: finalize
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'finalizepsbt', 'params':
[outputs.record_signature.row.psbt]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: test_accept
type: io.kestra.plugin.core.http.Request
description: Ask the node whether the network would accept the transaction
before broadcasting it. This also catches signatures that do not verify.
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'testmempoolaccept',
'params': [[outputs.finalize.body | jq('.result.hex') | first]]} | toJson
}}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: mempool_check
type: io.kestra.plugin.core.flow.If
condition: "{{ not (outputs.test_accept.body | jq('.result[0].allowed') | first) }}"
then:
- id: refuse_broadcast
type: io.kestra.plugin.core.execution.Fail
errorMessage: "The node would reject the signed transaction: {{
outputs.test_accept.body | jq('.result[0][\"reject-reason\"]') | first
}}"
- id: broadcast
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.rpc_url }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'sendrawtransaction',
'params': [outputs.finalize.body | jq('.result.hex') | first]} | toJson
}}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
- id: mark_broadcast
type: io.kestra.plugin.jdbc.postgresql.Query
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
parameters:
id: "{{ outputs.record_request.row.id }}"
txid: "{{ outputs.broadcast.body | jq('.result') | first }}"
sql: UPDATE spend_requests SET status = 'broadcast', txid = :txid, updated_at =
now() WHERE id = CAST(:id AS BIGINT)
- id: notify_broadcast
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "Treasury spend #{{ outputs.record_request.row.id }} broadcast: {{
outputs.record_request.row.amount }} BTC to {{
outputs.record_request.row.to_address }}, signed by {{
outputs.record_signature.row.signed_by }}. Transaction {{
outputs.broadcast.body | jq('.result') | first }}."
errors:
- id: release_on_failure
type: io.kestra.plugin.core.flow.If
description: Release the coins reserved by create_psbt. The list comes from the
decoded PSBT, not the database, so it works even when Postgres is the
problem.
condition: "{{ outputs.decode_psbt is defined }}"
then:
- id: release_coins
type: io.kestra.plugin.core.http.Request
uri: "{{ render(vars.wallet_rpc) }}"
method: POST
body: "{{ {'jsonrpc': '1.0', 'id': 'kestra', 'method': 'lockunspent', 'params':
[true, outputs.decode_psbt.body | jq('[.result.tx.vin[] | {txid,
vout}]') | first]} | toJson }}"
options:
auth:
type: BASIC
username: "{{ secret('BITCOIN_RPC_USER') }}"
password: "{{ secret('BITCOIN_RPC_PASSWORD') }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT30S
maxAttempts: 3
- id: mark_failed
type: io.kestra.plugin.jdbc.postgresql.Query
description: Record the failure. Requests that already ended (rejected,
deferred, broadcast) are left alone. allowFailure keeps the alert below
going out if the database is down.
allowFailure: true
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
parameters:
execution_id: "{{ execution.id }}"
reason: "{{ (errorLogs()[0]['message'] ?? 'signing window expired before enough
signatures arrived') | split(' \\[\\[') | first }}"
sql: |
UPDATE spend_requests SET status = 'failed', reason = :reason, updated_at = now()
WHERE execution_id = :execution_id AND status IN ('requested', 'awaiting_signatures')
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: >-
Treasury spend {% if outputs.record_request is defined %}#{{
outputs.record_request.row.id }} {% endif %}failed in execution {{
execution.id }} {% if errorLogs() | length > 0 %}at `{{
errorLogs()[0]['taskId'] }}`: {{ errorLogs()[0]['message'] | split('
\[\[') | first }}.{% else %}because the signing window expired before
enough signatures arrived.{% endif %} {% if outputs.decode_psbt is defined
%}The coins it had reserved were released. {% endif %}Nothing was
broadcast.
outputs:
- id: request_id
type: STRING
value: "{{ outputs.record_request.row.id ?? '' }}"
- id: txid
type: STRING
description: Transaction id of the broadcast spend.
value: "{{ (outputs.broadcast.body ?? '{}') | jq('.result // \"\"') | first }}"