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
:阈值 MMkeys: 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_pubkey
、new_pubkey
、valid_until
、query_id
每位签名者对“规范化序列化的提案消息体哈希”签名(ed25519)。
合约验证:
签名者是否在
keys
集合内seqno
、valid_until
、query_id
合法性去重计数,计满
k
执行
执行成功:
在 keys 集合中删除 old_pubkey,添加 new_pubkey
清理该 pending[query_id]
seqno++
重要:地址不变。因为地址绑定的是初始
StateInit
(初始 code+data)。运行时更改成员是状态迁移,不影响地址。
三、流程概览(2-of-3 替换 K3→K4)
读取当前链上状态:
keys = {K1,K2,K3}
,k=2
,seqno = s
构造提案:
op=REPLACE
,old=K3
,new=K4
,valid_until = now+600s
,query_id = random_u64
成员签名:至少两名现成员(如 K1、K2)分别签名同一提案哈希
依次提交签名:合约记录确认者集合
达阈值执行:合约状态更新
keys = {K1,K2,K4}
,seqno = s+1
验证:通过区块浏览器或
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], "...")
如何提交到链上
具体提交格式取决于合约的外部入口设计。常见两种模式:
单入口多义:
submit_or_confirm(body, signature)
首次收到
query_id
未存在 → 创建提案并记录第一个签名者再次收到相同
query_id
→ 累积签名
分离入口:
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
事件/日志中可见该管理操作的
op
、query_id
、旧/新公钥等字段地址保持不变(初始
StateInit
未改变)
六、安全性与实现要点
阈值与权限
管理操作应与资金操作同样要求 MM 个签名;不要为成员管理留后门或降低阈值。
域分离与重放防护
将
seqno
、valid_until
、query_id
纳入签名域,避免跨提案重放或跨合约重放。推荐在签名时加入明确的“域标签”(如上
msig_mgmt_v1
),避免与资金转账提案哈希冲突。
密钥去重与索引稳定
若用 array 存储
keys
,请定义稳定的排序(例如按字节序)以避免“同集合不同顺序”导致二义性。若用 dictionary,以 index 作为 key 时,请定义 index 的稳定分配策略;以
pubkey
的哈希作为 key 则更直接。
失败回滚
当
valid_until
过期时,合约应拒绝后续确认;必要时清理pending
。
可审计性
将管理操作的
op
常量、参数、签名者列表在事件日志中清晰记录,便于浏览器与审计工具解析。
七、扩展示例:拆分为 add/remove 两步
某些实现没有提供“replace”原子操作,可用两步代替:
添加新成员
add_custodian(K4)
(达阈值后执行)移除旧成员
remove_custodian(K3)
(再次达阈值后执行)
缺点:在两步之间,多签会短暂拥有 4 个成员,仍以原阈值 MM 执行。若对安全边界敏感,可在第一步完成后同时发起第二步,确保短时间窗口内完成。