关于 clarity-patterns
clarity-patterns 是面向 Stacks 智能合约开发的 Clarity 模式与模板库,供开发者和 Agent 查阅。它将代码、注册表、测试等参考内容随技能打包,无需外部依赖。
主要功能
- 01Clarity 合约代码模式
- 02注册表与存储模式
- 03完整合约模板及检查清单
- 04测试工具选择参考
适用场景
- 01构建带权限检查的合约函数
- 02设计链上注册表和索引
- 03为合约选择测试方法
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ aibtcdev/ skills --skill clarity-patterns安装后在 Claude Code 中输入/clarity-patterns确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
AGENT.mdtemplates/heartbeat-registry.mdproof-of-existence.mdregistry-minimal.md详细介绍
Clarity 模式
Clarity 智能合约开发的权威模式库,适用于 Stacks。所有模式和模板都已打包在此技能中,无需外部依赖。
这是一项仅包含文档的技能。Agent 会直接读取此文件和同目录下的参考文件。CLI 接口说明了计划中的实现。
bun run clarity-patterns/clarity-patterns.ts <subcommand> [options]
子命令
list [--category <category>]— 列出可用的模式和模板(类别:code、registry、templates、testing)get --name <pattern-name>— 返回指定模式及其代码和说明template --name <template-name>— 返回完整的合约模板,包括源代码、测试和检查清单
代码模式
公共函数模板
包含保护条件和错误处理的公共函数标准结构。
(define-public (transfer (amount uint) (to principal))
(begin
(asserts! (is-eq tx-sender owner) ERR_UNAUTHORIZED)
(try! (ft-transfer? TOKEN amount tx-sender to))
(ok true)))
- 使用
try!处理子调用,以传递错误 - 在状态变更前使用
asserts!设置保护条件 - 在 tx 上添加后置条件,以确保资产安全
标准化事件
发出结构化事件,以便链下索引。
(print {
notification: "contract-event",
payload: {
amount: amount,
sender: tx-sender,
recipient: to
}
})
notification:事件类型的字符串标识符payload:使用 camelCase 键的元组- 示例:usabtc-token、ccd002-treasury-v3
使用 Match 处理错误
妥善处理外部调用失败。
(match (contract-call? .other fn args)
success (ok success)
error (err ERR_EXTERNAL_CALL_FAILED))
用于状态/权限的位标志
将多个布尔值打包到单个 uint 中。
(define-constant STATUS_ACTIVE (pow u2 u0)) ;; 1
(define-constant STATUS_PAID (pow u2 u1)) ;; 2
(define-constant STATUS_VERIFIED (pow u2 u2)) ;; 4
;; Pack multiple flags: (+ STATUS_ACTIVE STATUS_PAID) → u3
;; Check flag: (> (bit-and status STATUS_ACTIVE) u0)
;; Set flag: (var-set status (bit-or (var-get status) NEW_FLAG))
;; Clear flag: (var-set status (bit-and (var-get status) (bit-not FLAG)))
示例:aibtc-action-proposal-voting
多重发送模式
使用 fold 在一笔交易中发送给多个接收方。
(define-private (send-maybe
(recipient {to: principal, ustx: uint})
(prior (response bool uint)))
(match prior
ok-result (let (
(to (get to recipient))
(ustx (get ustx recipient)))
(try! (stx-transfer? ustx tx-sender to))
(ok true))
err-result (err err-result)))
(define-public (send-many (recipients (list 200 {to: principal, ustx: uint})))
(fold send-maybe recipients (ok true)))
父子映射(分层数据)
存储支持分页的分层数据。
(define-map Parents uint {name: (string-ascii 32), lastChildId: uint})
(define-map Children {parentId: uint, id: uint} uint)
(define-read-only (get-child (parentId uint) (childId uint))
(map-get? Children {parentId: parentId, id: childId}))
(define-private (is-some? (x (optional uint)))
(is-some x))
(define-read-only (get-children (parentId uint) (shift uint))
(filter is-some?
(list
(get-child parentId (+ shift u1))
(get-child parentId (+ shift u2))
(get-child parentId (+ shift u3))
;; ... up to page size
)))
白名单(资产/合约)
控制哪些合约/资产可以交互。
(define-map Allowed {contract: principal, type: uint} bool)
;; Check in function
(asserts! (default-to false (map-get? Allowed {contract: contract, type: type}))
ERR_NOT_ALLOWED)
;; Batch update
(define-public (set-allowed-list (items (list 100 {token: principal, enabled: bool})))
(ok (map set-iter items (ok true))))
示例:ccd002-treasury-v3、aibtc-agent-account
Trait 白名单
仅允许来自可信 trait 实现的调用。
(define-map TrustedTraits principal bool)
;; In functions accepting traits
(asserts! (default-to false (map-get? TrustedTraits (contract-of t)))
ERR_UNTRUSTED)
延迟激活
经过一定数量的 Bitcoin 区块延迟后激活功能。
(define-constant DELAY u21000) ;; ~146 days in BTC blocks
(define-data-var activation-block uint u0)
;; Set on deploy or init
(var-set activation-block (+ burn-block-height DELAY))
(define-read-only (is-active?)
(>= burn-block-height (var-get activation-block)))
示例:usabtc-token
速率限制
防止短时间内重复执行操作。
(define-data-var last-action-block uint u0)
(define-public (rate-limited-action)
(begin
(asserts! (> burn-block-height (var-get last-action-block)) ERR_RATE_LIMIT)
(var-set last-action-block burn-block-height)
;; ... action
(ok true)))
使用快照投票的 DAO 提案(Stacks 3.4+)
注意: 使用
at-block的合约将在 Stacks 3.4 激活后(约 2026-04-02,BTC 区块 943,333)失效。at-block内置函数已在 Stacks 3.4 中移除(SIP-042)。使用at-block的 aibtcdev-daos DAO 合约需要在激活前迁移。
在创建提案时使用复合键映射存储代币余额快照。此方法无需使用 at-block,无需在读取时使用 filter/扫描列表,并能提供 O(1) 查找。
;; Stacks 3.4+: at-block removed. Store snapshot at proposal creation time.
;; Using a composite-key map for O(1) lookup — no filter/list scan needed.
(define-map ProposalSnapshots {proposalId: uint, voter: principal} uint)
(define-map Proposals uint {
votesFor: uint,
votesAgainst: uint,
status: uint,
liquidTokens: uint,
snapshotBlock: uint
})
;; At proposal creation: capture token balances for eligible voters
;; (caller supplies voter list; balances read from current block)
(define-public (create-proposal (voters (list 1000 principal)))
(let (
(proposal-id (+ (var-get last-proposal-id) u1))
(snapshot-block stacks-block-height))
;; fold is used (not map) because Clarity has no partial application —
;; map requires a bare function identifier, not a call expression.
;; proposal-id is threaded as the accumulator so store-snapshot can use it.
;; fold threads proposal-id as accumulator; store-snapshot fires map-set as side effect
(fold store-snapshot voters proposal-id)
(map-set Proposals proposal-id {
votesFor: u0, votesAgainst: u0,
status: u0, liquidTokens: u0,
snapshotBlock: snapshot-block})
(var-set last-proposal-id proposal-id)
(ok proposal-id)))
(define-private (store-snapshot (voter principal) (acc uint))
(begin
(map-set ProposalSnapshots
{proposalId: acc, voter: voter}
(unwrap! (contract-call? .token get-balance voter) u0))
acc))
;; O(1) lookup — no list scan, no filter
(define-read-only (get-vote-power (proposal-id uint) (voter principal))
(default-to u0
(map-get? ProposalSnapshots {proposalId: proposal-id, voter: voter})))
;; Quorum check: (>= (/ (* total-votes u100) liquid-supply) QUORUM_PERCENT)
要点:
- 不使用带闭包的
(filter ...)— Clarity 不支持部分应用。复合键映射可完全替代 filter 模式。 default-to u0可正常工作 —map-get?返回(optional uint),因此default-to u0能处理缺失的投票者,而不会触发 panic(不同于对过滤后的列表使用unwrap-panic)。- O(1) 查找,而非读取时进行 O(n) 列表扫描。
示例:aibtcdev-daos — 使用 at-block 的 DAO 合约需要在 Stacks 3.4 激活前迁移。
定点算术
使用比例因子处理小数值。
(define-constant SCALE (pow u10 u8)) ;; 8 decimal places
;; Multiply then divide to preserve precision
(define-read-only (calculate-share (amount uint) (percentage uint))
(/ (* amount percentage) SCALE))
;; Convert to/from scaled values
(define-read-only (to-scaled (amount uint))
(* amount SCALE))
(define-read-only (from-scaled (amount uint))
(/ amount SCALE))
使用 as-contract 的资金库模式
对于由合约控制的资金,请使用 as-contract。
(define-public (withdraw (amount uint) (recipient principal))
(begin
(asserts! (is-authorized tx-sender) ERR_UNAUTHORIZED)
(as-contract (stx-transfer? amount (as-contract tx-sender) recipient))))
警告:as-contract 会将 tx-sender 和 contract-caller 都更改为合约主体。
tx-sender 与 contract-caller 的决策框架
| 调用路径 | contract-caller | tx-sender |
|---|---|---|
| 用户 -> 目标 | 用户 | 用户 |
| 用户 -> 代理 -> 目标 | 代理 | 用户 |
| 用户 -> 代理 (as-contract) -> 目标 | 代理 | 代理 |
- tx-sender:用于身份验证检查、身份归属和自我操作保护。通过普通代理时保留人的身份。更适合可组合性。
- contract-caller:仅在需要获取直接调用者的身份时使用。
- 安全提示:将
contract-caller用于自我操作保护(例如“所有者不能给自己反馈”)可以被绕过——所有者可以通过任意代理发起调用,此时contract-caller显示的是代理,而非所有者。tx-sender会保留人的来源身份,因此能够拦截此类操作。
示例:ccd002-treasury-v3、aibtc-agent-account
Clarity 4:资产限制
限制合约调用可转移的资产。
(as-contract
(with-stx u1000000) ;; Allow 1 STX
(with-ft .token TOKEN u500) ;; Allow 500 fungible tokens
(with-nft .nft-contract NFT (list u1 u2 u3)) ;; Allow specific NFT IDs
;; ... body
)
;; DANGER: Avoid unless necessary
(with-all-assets-unsafe)
多方协调
协调需要多个签名的操作。
;; Proposal state
(define-map Intents uint {
participants: (list 20 principal),
accepts: uint, ;; Bitmask of who accepted
status: uint, ;; 0=pending, 1=ready, 2=executed, 3=cancelled
expiry: uint,
payload: (buff 256)
})
;; Accept via signature verification
(define-public (accept (intent-id uint) (signature (buff 65)))
(let (
(intent (unwrap! (map-get? Intents intent-id) ERR_NOT_FOUND))
(msg-hash (sha256 (concat (int-to-ascii intent-id) (get payload intent))))
(signer (try! (secp256k1-recover? msg-hash signature))))
;; Verify signer is participant, update accepts bitmask
(ok true)))
参考:用于可判定多方协调的 ERC-8001 模式。
注册表模式
区块快照模式
在交易执行时捕获全面的链状态。这是让交易物有所值的“收据”。
;; Full snapshot — comprehensive (use for high-value records)
(define-private (capture-snapshot)
{
stacksBlock: stacks-block-height,
burnBlock: burn-block-height,
tenure: tenure-height,
blockTime: stacks-block-time,
chainId: chain-id,
txSender: tx-sender,
contractCaller: contract-caller,
txSponsor: tx-sponsor?,
stacksBlockHash: (get-stacks-block-info? id-header-hash (- stacks-block-height u1)),
burnBlockHash: (get-burn-block-info? header-hash (- burn-block-height u1))
}
)
;; Standard snapshot — balanced cost
;; {stacksBlock, burnBlock, blockTime, txSender}
;; Minimal snapshot — cheapest
;; {stacksBlock, burnBlock}
之所以捕获前一区块的哈希,是因为当前区块的哈希要等交易完成后才会最终确定。前一区块的哈希不可变,且可独立验证。
以主体为键的注册表
按地址跟踪状态(心跳、资料、余额)。每个地址对应一条记录,后续调用会覆盖该记录。
(define-map Registry
principal
{
stacksBlock: uint,
burnBlock: uint,
count: uint
}
)
(map-get? Registry address)
(map-set Registry tx-sender {...})
以哈希为键的注册表
跟踪唯一数据(证明、承诺)。每个唯一哈希对应一条记录,首次写入生效。
(define-map Registry
(buff 32)
{
attestor: principal,
stacksBlock: uint
}
)
;; First attestor wins
(asserts! (is-none (map-get? Registry hash)) ERR_ALREADY_EXISTS)
(map-set Registry hash {...})
复合键注册表
跟踪多个维度的数据(每个提案的投票、每个 Agent 的操作)。
(define-map Registry
{entity: principal, action: uint}
{stacksBlock: uint}
)
(map-get? Registry {entity: address, action: action-id})
二级索引模式
当主键不是地址时,支持按地址枚举条目。
;; Primary: hash -> data
(define-map Attestations (buff 32) {...})
;; Secondary: address + index -> hash
(define-map AttestorIndex
{attestor: principal, index: uint}
(buff 32)
)
;; Counter for next index
(define-map AttestorCount principal uint)
;; On insert:
(let ((idx (default-to u0 (map-get? AttestorCount attestor))))
(map-set AttestorIndex {attestor: attestor, index: idx} hash)
(map-set AttestorCount attestor (+ idx u1)))
;; Enumerate:
(define-read-only (get-attestor-hash-at (attestor principal) (index uint))
(map-get? AttestorIndex {attestor: attestor, index: index}))
全局统计模式
无需遍历即可跟踪汇总指标。
(define-data-var totalEntries uint u0)
(define-data-var uniqueAddresses uint u0)
;; On new entry:
(var-set totalEntries (+ (var-get totalEntries) u1))
(if isNewAddress
(var-set uniqueAddresses (+ (var-get uniqueAddresses) u1))
true)
;; Read stats:
(define-read-only (get-stats)
{
totalEntries: (var-get totalEntries),
uniqueAddresses: (var-get uniqueAddresses)
}
)
写入语义
首次写入生效(证明):
(define-public (attest (key (buff 32)))
(begin
(asserts! (is-none (map-get? Registry key)) ERR_ALREADY_EXISTS)
(map-set Registry key {...})
(ok true)))
最后写入生效(心跳):
(define-public (check-in)
(begin
(map-set Registry tx-sender {...})
(ok true)))
仅追加(历史记录):
(define-map History
{address: principal, index: uint}
{...snapshot...}
)
(define-public (record)
(let ((idx (default-to u0 (map-get? HistoryCount tx-sender))))
(map-set History {address: tx-sender, index: idx} {...})
(map-set HistoryCount tx-sender (+ idx u1))
(ok idx)))
访问控制模式
开放(任何人都可以写入):
(define-public (register)
(ok (map-set Registry tx-sender {...})))
仅本人(已注册用户只能更新自己的条目):
(define-public (update (data (buff 64)))
(begin
(asserts! (is-some (map-get? Registry tx-sender)) ERR_NOT_REGISTERED)
(map-set Registry tx-sender {...})
(ok true)))
管理员控制:
(define-data-var admin principal CONTRACT_OWNER)
(define-public (register-address (address principal))
(begin
(asserts! (is-eq tx-sender (var-get admin)) ERR_UNAUTHORIZED)
(map-set Registry address {...})
(ok true)))
测试参考
测试金字塔
Stxer (Historical Simulation) — Mainnet fork, pre-deployment validation
RV (Property-Based Fuzzing) — Invariants, edge cases, battle-grade
Vitest + Clarinet SDK — Integration tests, TypeScript
Clarunit — Unit tests in Clarity itself
各工具的适用场景
| 工具 | 适用场景 | 不适用场景 |
|---|---|---|
| Clarinet SDK | 标准测试、CI/CD、类型安全 | - |
| Clarunit | 使用 Clarity 测试 Clarity 逻辑、简单断言 | 复杂的多账户流程 |
| RV | 资金库、DAO、高价值合约、查找边界情况 | 简单合约、时间紧迫 |
| Stxer | 主网前验证、治理模拟 | 早期开发、仅限测试网 |
Vitest 配置
import { defineConfig } from "vitest/config";
import { vitestSetupFilePath, getClarinetVitestsArgv } from "@hirosystems/clarinet-sdk/vitest";
export default defineConfig({
test: {
environment: "clarinet",
singleThread: true,
setupFiles: [vitestSetupFilePath],
environmentOptions: {
clarinet: getClarinetVitestsArgv(),
},
},
});
测试结构(准备-执行-断言)
import { Cl } from "@stacks/transactions";
import { describe, expect, it } from "vitest";
describe("my-contract", function () {
it("transfers tokens correctly", function () {
// ARRANGE
const deployer = simnet.deployer;
const wallet1 = simnet.getAccounts().get("wallet_1")!;
const amount = 100;
// ACT
const result = simnet.callPublicFn(
"my-contract",
"transfer",
[Cl.uint(amount), Cl.principal(wallet1)],
deployer
);
// ASSERT
expect(result.result).toBeOk(Cl.bool(true));
});
});
注意事项
- 不要使用
beforeAll/beforeEach— 每个测试文件会话都会重置 simnet - 必须使用单线程 — 使用
singleThread: true隔离 simnet - 测试辅助函数使用普通函数,而不是箭头函数
- 使用
cvToValue()和cvToJSON()将 Clarity 值转换为 JS
Clarity 值构造函数
import { Cl, cvToValue, cvToJSON } from "@stacks/transactions";
Cl.uint(100) // uint
Cl.int(-50) // int
Cl.bool(true) // bool
Cl.principal("SP123...") // principal
Cl.contractPrincipal("SP123", "name") // contract principal
Cl.stringAscii("hello") // (string-ascii N)
Cl.stringUtf8("hello") // (string-utf8 N)
Cl.bufferFromHex("deadbeef") // (buff N)
Cl.tuple({ amount: Cl.uint(100) }) // tuple
Cl.list([Cl.uint(1), Cl.uint(2)]) // list
Cl.some(Cl.uint(100)) // (some value)
Cl.none() // none
自定义匹配器
expect(result.result).toBeOk(Cl.uint(100));
expect(result.result).toBeErr(Cl.uint(1));
expect(result.result).toBeBool(true);
expect(result.result).toBeUint(100);
expect(result.result).toBePrincipal("SP123...");
RV(Rendezvous)模糊测试
;; Property: loan amount always increases correctly
(define-public (test-borrow (amount uint))
(if (is-eq amount u0)
(ok false) ;; Discard invalid input
(let ((initial (get-loan tx-sender)))
(try! (borrow amount))
(asserts! (is-eq (get-loan tx-sender) (+ initial amount))
(err u999))
(ok true))))
;; Invariant: total supply never exceeds cap
(define-read-only (invariant-supply-capped)
(<= (var-get total-supply) MAX_SUPPLY))
运行:npx rv . my-contract test(属性),npx rv . my-contract invariant(不变量)
Clarunit 测试
;; @name Multiplication works correctly
(define-public (test-multiply)
(begin
(asserts! (is-eq u8 (contract-call? .math multiply u2 u4))
(err "2 * 4 should equal 8"))
(ok true)))
文件:tests/my-contract_test.clar,函数以 test- 开头。
项目结构(全栈)
my-project/
├── Clarinet.toml
├── vitest.config.js
├── package.json
├── contracts/
│ ├── my-contract.clar
│ └── my-contract.tests.clar # RV tests
├── tests/
│ ├── my-contract.test.ts # Vitest
│ ├── my-contract_test.clar # Clarunit
│ └── clarunit.test.ts # Clarunit runner
└── simulations/
└── my-contract-stxer.ts # Stxer
package.json 脚本
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:rv": "npx rv . my-contract test",
"test:rv:invariant": "npx rv . my-contract invariant",
"test:stxer": "npx tsx simulations/my-contract-stxer.ts"
}
}
合约模板
完整的合约模板及其源文件和测试位于同目录文件中:
| 模板 | 文件 | 描述 |
|---|---|---|
heartbeat-registry | templates/heartbeat-registry.md | Agent 心跳,包含完整的链上下文、地址枚举和存活性检查 |
proof-of-existence | templates/proof-of-existence.md | 使用 SIP-018 签名的文档时间戳记录、首次写入优先和证明者索引 |
registry-minimal | templates/registry-minimal.md | 结合快照、统计信息和事件的最小注册表 |
执行成本限制
| 类别 | 区块限制 | 只读限制 |
|---|---|---|
| 运行时 | 5,000,000,000 | 1,000,000,000 |
| 读取次数 | 15,000 | 30 |
| 读取字节数 | 100,000,000 | 100,000 |
| 写入次数 | 15,000 | 0 |
| 写入字节数 | 15,000,000 | 0 |
成本优化提示
- 内联仅使用一次的值 — 避免不必要的
let绑定 - 优先使用常量而非 data-vars — 读取常量的成本更低
- 批量操作 — 一次调用并传入列表优于多次调用
- 分开使用参数和元组 — 扁平参数的函数调用成本更低
- 链下计算 — 将非必要逻辑移至 UI/indexer
快速参考
- 使用
stacks-block-height,而不是已弃用的block-height - 代币操作使用
tx-sender,仅在需要即时调用者身份时使用contract-caller - 使用
try!传播错误,在状态变更前使用asserts!进行检查 - 所有公共函数都必须返回
(response ok err) - 错误代码应为唯一常量
- 事件格式:
{notification: "event-name", payload: {...}} - 在 clarinet 控制台中使用
::get_costs检查成本
参考资料
- friedger/clarity-ccip-026 — 已集成全部 4 种测试工具
- kenny-stacks/stacks-starter — 测试设置参考
- aibtcdev/aibtcdev-daos — DAO 模式
- citycoins/protocol — 代币和资金库模式
- clarigen — 从合约生成 TypeScript 类型
- secondlayer — 另一种类型生成器