mcpField
C
clarity-patterns

clarity-patterns

github.com/aibtcdev开发11107 次安装1 天前更新
代码仓库

关于 clarity-patterns

clarity-patterns 是面向 Stacks 智能合约开发的 Clarity 模式与模板库,供开发者和 Agent 查阅。它将代码、注册表、测试等参考内容随技能打包,无需外部依赖。

主要功能

  1. 01Clarity 合约代码模式
  2. 02注册表与存储模式
  3. 03完整合约模板及检查清单
  4. 04测试工具选择参考

适用场景

  1. 01构建带权限检查的合约函数
  2. 02设计链上注册表和索引
  3. 03为合约选择测试方法

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/aibtcdev/skills --skill clarity-patterns

安装后在 Claude Code 中输入/clarity-patterns确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

AGENT.md
templates/
heartbeat-registry.md
proof-of-existence.md
registry-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
  }
})
使用 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))

示例:ccd012-redemption-nyc

使用 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-callertx-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-registrytemplates/heartbeat-registry.mdAgent 心跳,包含完整的链上下文、地址枚举和存活性检查
proof-of-existencetemplates/proof-of-existence.md使用 SIP-018 签名的文档时间戳记录、首次写入优先和证明者索引
registry-minimaltemplates/registry-minimal.md结合快照、统计信息和事件的最小注册表

执行成本限制

类别区块限制只读限制
运行时5,000,000,0001,000,000,000
读取次数15,00030
读取字节数100,000,000100,000
写入次数15,0000
写入字节数15,000,0000
成本优化提示
  1. 内联仅使用一次的值 — 避免不必要的 let 绑定
  2. 优先使用常量而非 data-vars — 读取常量的成本更低
  3. 批量操作 — 一次调用并传入列表优于多次调用
  4. 分开使用参数和元组 — 扁平参数的函数调用成本更低
  5. 链下计算 — 将非必要逻辑移至 UI/indexer

快速参考

  • 使用 stacks-block-height,而不是已弃用的 block-height
  • 代币操作使用 tx-sender,仅在需要即时调用者身份时使用 contract-caller
  • 使用 try! 传播错误,在状态变更前使用 asserts! 进行检查
  • 所有公共函数都必须返回 (response ok err)
  • 错误代码应为唯一常量
  • 事件格式:{notification: "event-name", payload: {...}}
  • 在 clarinet 控制台中使用 ::get_costs 检查成本

参考资料

评论

支持 Markdown