Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Clarinet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ contract_id = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standa
[[project.requirements]]
contract_id = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token'



# Trait contracts (must be listed first for dependencies)
[contracts.dao-traits]
path = "contracts/traits/dao-traits.clar"
Expand All @@ -21,6 +23,11 @@ epoch = "3.0"
path = "contracts/traits/agent-traits.clar"
epoch = "3.0"

# Heartbeat (no dependencies, must load before token/extensions)
[contracts.heartbeat]
path = "contracts/heartbeat.clar"
epoch = "3.0"

# DAO contracts (depend on traits)
[contracts.base-dao]
path = "contracts/dao/base-dao.clar"
Expand All @@ -31,6 +38,13 @@ epoch = "3.0"
path = "contracts/token/mock-sbtc.clar"
epoch = "3.0"

# Identity registry: mock in simnet, maps to real identity-registry-v2 on mainnet
# via deployment plan. Matches the interface of
# SP1NMR7MY0TJ1QA7WQBZ6504KC79PZNTRQH4YGFJD.identity-registry-v2
[contracts.identity-registry]
path = "contracts/mocks/mock-identity-registry.clar"
epoch = "3.0"

# Config contracts
[contracts.sbtc-config]
path = "contracts/config/sbtc-config.clar"
Expand Down Expand Up @@ -61,6 +75,10 @@ epoch = "3.0"
path = "contracts/extensions/core-proposals.clar"
epoch = "3.0"

[contracts.publisher-role]
path = "contracts/extensions/publisher-role.clar"
epoch = "3.0"

# Proposal contracts
[contracts.test-proposal]
path = "contracts/proposals/test-proposal.clar"
Expand All @@ -70,6 +88,10 @@ epoch = "3.0"
path = "contracts/proposals/init-proposal.clar"
epoch = "3.0"

[contracts.btc-binding]
path = "contracts/btc-binding.clar"
epoch = "3.0"

# Agent contracts (depend on base-dao)
[contracts.agent-registry]
path = "contracts/agent/agent-registry.clar"
Expand All @@ -79,6 +101,10 @@ epoch = "3.0"
path = "contracts/agent/agent-account.clar"
epoch = "3.0"

[contracts.aibtc-token]
path = "contracts/token/aibtc-token.clar"
epoch = "3.0"

[contracts.checkin-registry]
path = "contracts/checkin-registry.clar"
epoch = "3.0"
Expand Down
154 changes: 154 additions & 0 deletions contracts/btc-binding.clar
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
;; title: btc-binding
;; version: 1.0.0
;; summary: L1-L2 identity link -- verifies BTC ownership on-chain.
;; description: Agents prove they control a BTC address by signing a
;; challenge message with their BTC private key. The contract recovers
;; the public key via secp256k1 and stores the verified binding.
;; This bridges Bitcoin L1 identity with Stacks L2 identity,
;; complementing the ERC-8004 identity registry.

;; =========================================
;; CONSTANTS
;; =========================================

(define-constant ERR_INVALID_SIGNATURE (err u4000))
(define-constant ERR_KEY_MISMATCH (err u4001))
(define-constant ERR_ALREADY_BOUND (err u4002))
(define-constant ERR_NOT_AUTHORIZED (err u4003))

;; The challenge message agents must sign to prove BTC ownership.
;; Using a fixed domain-separated string prevents replay attacks
;; across different protocols.
;;
;; NOTE: This uses plain sha256, NOT BIP-137/BIP-322 Bitcoin message signing.
;; Standard wallet signMessage() (Leather, Xverse) won't produce compatible
;; signatures. Agents must use custom signing code or the agent SDK.
;; This is intentional -- BIP-137 adds variable-length encoding that
;; complicates on-chain recovery. Document this for implementors.
(define-constant BINDING_CHALLENGE 0x414942544320425443204f776e65727368697020566572696669636174696f6e)
;; = "AIBTC BTC Ownership Verification" in hex
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could use this or repeat our "Bitcoin will be the currency of AIs" text from landing-page

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. Could use the "Bitcoin will be the currency of AIs" text for brand consistency. Open to either -- will defer to team preference.


;; =========================================
;; DATA STORAGE
;; =========================================

;; Maps Stacks principal to their verified BTC public key (33-byte compressed)
(define-map btc-bindings principal (buff 33))

;; Reverse map: BTC pubkey to Stacks principal (prevents one key binding to multiple principals)
(define-map reverse-bindings (buff 33) principal)

;; Total verified bindings
(define-data-var total-bindings uint u0)

;; =========================================
;; PUBLIC FUNCTIONS
;; =========================================

;; @desc Verify BTC ownership and bind the recovered pubkey to tx-sender.
;; Agent signs BINDING_CHALLENGE with their BTC key, submits the signature.
;; Contract recovers the pubkey and stores the binding.
;; @param signature - 65-byte recoverable signature (r, s, recovery-id)
;; @returns (response (buff 33) uint) - the verified public key
(define-public (bind-btc (signature (buff 65)))
(let
(
(caller tx-sender)
(message-hash (sha256 BINDING_CHALLENGE))
(recovered-key (unwrap! (secp256k1-recover? message-hash signature) ERR_INVALID_SIGNATURE))
)
;; Check this pubkey isn't already bound to a different principal
(match (map-get? reverse-bindings recovered-key)
existing-principal
(asserts! (is-eq existing-principal caller) ERR_ALREADY_BOUND)
true
)

;; Capture first-binding status BEFORE map-set (map-set overwrites, making is-none always false after)
(let
(
(is-new (is-none (map-get? btc-bindings caller)))
)
;; Store the binding
(map-set btc-bindings caller recovered-key)
(map-set reverse-bindings recovered-key caller)

;; Increment counter on first binding only
(if is-new
(var-set total-bindings (+ (var-get total-bindings) u1))
false
)

;; Record heartbeat
(try! (contract-call? .heartbeat beat caller))

(print {
notification: "btc-binding/bind",
payload: {
principal: caller,
btc-pubkey: recovered-key
}
})
(ok recovered-key)
)
)
)

;; @desc Remove BTC binding for tx-sender. Only the bound principal can unbind.
;; @returns (response bool uint)
(define-public (unbind-btc)
(let
(
(caller tx-sender)
(current-key (unwrap! (map-get? btc-bindings caller) ERR_KEY_MISMATCH))
)
(map-delete btc-bindings caller)
(map-delete reverse-bindings current-key)

(print {
notification: "btc-binding/unbind",
payload: {
principal: caller,
removed-pubkey: current-key
}
})
(ok true)
)
)

;; =========================================
;; READ-ONLY FUNCTIONS
;; =========================================

;; @desc Get the verified BTC public key for a Stacks principal
;; @param who - The principal to query
;; @returns (optional (buff 33)) - compressed pubkey or none
(define-read-only (get-btc-key (who principal))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious how we can get from pubkey to address here, there was a gist for base58-decode where you could go from Stacks address to Bitcoin address that could be helpful. Likely filed as a future issue just don't want to forget.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted -- filed mentally as a Phase 1 enhancement. The base58-decode gist would let us verify the binding on-chain without external lookups. Will open a follow-up issue.

(map-get? btc-bindings who)
)

;; @desc Get the Stacks principal bound to a BTC public key
;; @param pubkey - 33-byte compressed public key
;; @returns (optional principal) - Stacks principal or none
(define-read-only (get-principal-for-key (pubkey (buff 33)))
(map-get? reverse-bindings pubkey)
)

;; @desc Check if a principal has a verified BTC binding
;; @param who - The principal to check
;; @returns bool
(define-read-only (is-bound (who principal))
(is-some (map-get? btc-bindings who))
)

;; @desc Get total number of verified bindings
;; @returns uint
(define-read-only (get-total-bindings)
(var-get total-bindings)
)

;; @desc Get the challenge message that must be signed
;; @returns (buff 32) - the challenge bytes
(define-read-only (get-challenge)
BINDING_CHALLENGE
)
Loading