文章

Ton 修改多签与注意

TON 多签合约:修改成员(增删钥或换人)

说明:多签并无唯一官方实现。以下以社区常见 SafeMultisig/SetcodeMultisig 风格进行抽象,接口名与 TL‑B 布局可能因实现不同而异。若你提供具体仓库或 ABI,我可给出精确到位宽的代码与序列化。


一、目标

  • 初始多签:成员集合 {K1,K2,K3}{K1​,K2​,K3​},阈值 M=2M=2(2-of-3)。

  • 目标变更:将 K3K3​ 替换为 K4K4​,仍保持 2-of-3。

  • 约束:修改必须走现有阈值流程,防止单点控制导致任意换人。


二、底层原理与状态结构

1) 多签合约的关键状态

  • k: uint16:阈值 MM

  • keys: dict/array<pubkey: uint256>:当前成员公钥集合

  • seqno: uint32:外部消息防重放

  • pending: dict<query_id -> Proposal>:待执行提案池(每个提案含目标、参数、已确认成员集合)

  • 可选管理位:是否允许升级、是否允许成员管理等

不同实现对 keys 存储采用 array 或 dictionary。多数现代实现偏好 dictionary(键为索引或 pubkey 哈希),便于去重与查找。

2) 修改成员的操作抽象

  • 以“管理操作”形式实现,常见有:

    • add_custodian(pubkey)

    • remove_custodian(pubkey)

    • replace_custodian(old_pubkey, new_pubkey)

    • set_threshold(new_k)

  • 这些操作本质是“对多签状态的写操作”,必须通过提案+多签确认达成 MM 签。

3) 提案与签名校验

  • 发起者构造管理提案 op = OP_REPLACE_CUSTODIAN,携带参数:

    • old_pubkeynew_pubkeyvalid_untilquery_id

  • 每位签名者对“规范化序列化的提案消息体哈希”签名(ed25519)。

  • 合约验证:

    • 签名者是否在 keys 集合内

    • seqnovalid_untilquery_id 合法性

    • 去重计数,计满 k 执行

  • 执行成功:

    • 在 keys 集合中删除 old_pubkey,添加 new_pubkey

    • 清理该 pending[query_id]

    • seqno++

重要:地址不变。因为地址绑定的是初始 StateInit(初始 code+data)。运行时更改成员是状态迁移,不影响地址。


三、流程概览(2-of-3 替换 K3→K4)

  1. 读取当前链上状态keys = {K1,K2,K3}k=2seqno = s

  2. 构造提案op=REPLACEold=K3new=K4valid_until = now+600squery_id = random_u64

  3. 成员签名:至少两名现成员(如 K1、K2)分别签名同一提案哈希

  4. 依次提交签名:合约记录确认者集合

  5. 达阈值执行:合约状态更新 keys = {K1,K2,K4}seqno = s+1

  6. 验证:通过区块浏览器或 get 方法读取 keys,确认变更


四、Python 示例:离线构造“更换成员”的提案消息

本段示例演示如何在前端/脚本侧构造“管理提案”的消息体与签名,模拟 SafeMultisig 风格流程。

使用 tonsdk 来处理 Cell/BOC。签名用 ed25519(pynacl)。

# pip install tonsdk pynacl
import time
import os
import struct
from nacl.signing import SigningKey
from tonsdk.boc import Builder, Cell
from tonsdk.utils import Address

# 假设链上多签合约地址(已部署)
MULTISIG_ADDR_STR = "EQC...your_multisig_address"
multisig_addr = Address(MULTISIG_ADDR_STR)

# ---- 假设的 OP 常量(按你的合约实现替换)----
OP_REPLACE_CUSTODIAN = 0x1003  # 示例值,占位
# 其他可能有 OP_ADD=0x1001, OP_REMOVE=0x1002, OP_SET_THRESHOLD=0x1004 ...

def build_replace_custodian_body(old_pubkey: bytes, new_pubkey: bytes,
                                 valid_until: int, query_id: int) -> Cell:
    """
    构造管理提案的消息体(body),字段与位宽需与合约实现一致。
    这里示意使用:op:uint32 | query_id:uint64 | valid_until:uint32 | old:256 | new:256
    """
    assert len(old_pubkey) == 32 and len(new_pubkey) == 32
    b = Builder()
    b.store_uint(OP_REPLACE_CUSTODIAN, 32)
    b.store_uint(query_id, 64)
    b.store_uint(valid_until, 32)
    b.store_bytes(old_pubkey)  # 256 bits
    b.store_bytes(new_pubkey)  # 256 bits
    return b.end_cell()

def canonical_message_hash(multisig_addr: Address, seqno: int, body: Cell) -> bytes:
    """
    生成用于签名的规范哈希。不同多签对签名域的定义不同。
    常见做法:哈希 {target_addr, seqno, body_hash, maybe: workchain, op_domain}
    也有实现直接对外部消息全体进行签名。
    这里用一个常见的“域分离”做法:hash = sha256("msig_mgmt" || addr_hash || seqno || body_hash)
    """
    import hashlib
    tag = b"msig_mgmt_v1"  # 域分离,防串用
    addr_hash = bytes.fromhex(multisig_addr.hash_part.hex())
    body_hash = body.hash
    data = tag + addr_hash + struct.pack(">I", seqno) + body_hash
    return hashlib.sha256(data).digest()

def sign_with_ed25519(secret_key: bytes, digest: bytes) -> bytes:
    """
    使用 ed25519 对 digest 签名,返回 64 字节签名。
    """
    sk = SigningKey(secret_key)
    sig = sk.sign(digest).signature  # 64 bytes
    return sig

# ---- Demo: 构造更换成员提案 K3 -> K4,并由 K1 与 K2 进行签名 ----

# 假设当前链上 seqno(应通过 RPC getter 获取,这里硬编码做演示)
current_seqno = 42

# 现有公钥(32字节),K1/K2/K3;新公钥 K4
K1 = bytes.fromhex("11"*32)
K2 = bytes.fromhex("22"*32)
K3 = bytes.fromhex("33"*32)
K4 = os.urandom(32)  # 新成员公钥,示例随机;实际应为对方提供的真实公钥

# 构造管理 body
valid_until = int(time.time()) + 600
query_id = int.from_bytes(os.urandom(8), "big")

body = build_replace_custodian_body(K3, K4, valid_until, query_id)

# 参与签名的两位成员的私钥(示例随机生成;实际应为各自安全保存的ed25519私钥)
sk1 = os.urandom(32)
sk2 = os.urandom(32)

# 计算规范消息哈希并签名
digest = canonical_message_hash(multisig_addr, current_seqno, body)
sig1 = sign_with_ed25519(sk1, digest)  # K1 对应的私钥
sig2 = sign_with_ed25519(sk2, digest)  # K2 对应的私钥

print("query_id:", query_id)
print("digest:", digest.hex())
print("sig1:", sig1.hex()[:20], "...")
print("sig2:", sig2.hex()[:20], "...")

如何提交到链上

  • 具体提交格式取决于合约的外部入口设计。常见两种模式:

    1. 单入口多义submit_or_confirm(body, signature)

      • 首次收到 query_id 未存在 → 创建提案并记录第一个签名者

      • 再次收到相同 query_id → 累积签名

    2. 分离入口submit_proposal(body, signature) + confirm_proposal(query_id, signature)

  • 有的实现允许一次外部消息携带多份签名批次提交(节省费用,但要实现支持)。

在 SDK 中,你需要将 body 作为外部消息载荷,按钱包/SDK 规范封装 ext_in_msg 并从一个签名者地址(或免签入口,依据合约)发起。


五、链上执行后的状态变化

  • keys 集合变为 {K1,K2,K4}{K1​,K2​,K4​},K_3 被移除

  • pending[query_id] 被删除(提案已完成)

  • seqno = seqno + 1

  • 事件/日志中可见该管理操作的 opquery_id、旧/新公钥等字段

  • 地址保持不变(初始 StateInit 未改变)


六、安全性与实现要点

  • 阈值与权限

    • 管理操作应与资金操作同样要求 MM 个签名;不要为成员管理留后门或降低阈值。

  • 域分离与重放防护

    • 将 seqnovalid_untilquery_id 纳入签名域,避免跨提案重放或跨合约重放。

    • 推荐在签名时加入明确的“域标签”(如上 msig_mgmt_v1),避免与资金转账提案哈希冲突。

  • 密钥去重与索引稳定

    • 若用 array 存储 keys,请定义稳定的排序(例如按字节序)以避免“同集合不同顺序”导致二义性。

    • 若用 dictionary,以 index 作为 key 时,请定义 index 的稳定分配策略;以 pubkey的哈希作为 key 则更直接。

  • 失败回滚

    • 当 valid_until 过期时,合约应拒绝后续确认;必要时清理 pending

  • 可审计性

    • 将管理操作的 op 常量、参数、签名者列表在事件日志中清晰记录,便于浏览器与审计工具解析。


七、扩展示例:拆分为 add/remove 两步

某些实现没有提供“replace”原子操作,可用两步代替:

  1. 添加新成员 add_custodian(K4)(达阈值后执行)

  2. 移除旧成员 remove_custodian(K3)(再次达阈值后执行)

缺点:在两步之间,多签会短暂拥有 4 个成员,仍以原阈值 MM 执行。若对安全边界敏感,可在第一步完成后同时发起第二步,确保短时间窗口内完成。

许可协议:  CC BY 4.0