diff --git a/chain33.fork.toml b/chain33.fork.toml index e26dcf12aa..237f66165a 100644 --- a/chain33.fork.toml +++ b/chain33.fork.toml @@ -216,6 +216,8 @@ ForkTokenSymbolWithNumber=1298600 ForkTokenCheck=1600000 # 增加Erc20合约对token 合约的支持 ForkTokenEvm=0 +# finishCreate时全局检查token symbol唯一性,防止同一symbol被不同owner重复finish超发 +ForkTokenFinishCheck=0 [fork.sub.trade] Enable=100899 diff --git a/chain33.para.toml b/chain33.para.toml index e877dbd938..12367ec6a5 100644 --- a/chain33.para.toml +++ b/chain33.para.toml @@ -366,6 +366,7 @@ ForkTokenPrice=0 ForkTokenSymbolWithNumber=0 ForkTokenCheck= 0 ForkTokenEvm=0 +ForkTokenFinishCheck=0 [fork.sub.trade] Enable=0 diff --git a/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml b/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml index 7588e667dd..c7e7172587 100644 --- a/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml +++ b/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml @@ -714,6 +714,8 @@ ForkTokenSymbolWithNumber=1298600 ForkTokenCheck=1600000 # 增加Erc20合约对token 合约的支持 ForkTokenEvm=0 +# finishCreate时全局检查token symbol唯一性,防止同一symbol被不同owner重复finish超发 +ForkTokenFinishCheck=0 [fork.sub.trade] Enable=100899 diff --git a/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml b/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml index 3ea1726f6d..2e7ba2d785 100644 --- a/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml +++ b/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml @@ -236,6 +236,7 @@ ForkBadTokenSymbol= 0 ForkTokenPrice=0 ForkTokenSymbolWithNumber=0 ForkTokenCheck= 0 +ForkTokenFinishCheck= 0 [fork.sub.manage] Enable=0 diff --git a/plugin/dapp/token/executor/token_finishcheck_test.go b/plugin/dapp/token/executor/token_finishcheck_test.go new file mode 100644 index 0000000000..d96b136ae7 --- /dev/null +++ b/plugin/dapp/token/executor/token_finishcheck_test.go @@ -0,0 +1,175 @@ +// Copyright Fuzamei Corp. 2018 All Rights Reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package executor + +import ( + "strings" + "sync/atomic" + "testing" + + "github.com/33cn/chain33/account" + apimock "github.com/33cn/chain33/client/mocks" + dbm "github.com/33cn/chain33/common/db" + "github.com/33cn/chain33/types" + "github.com/33cn/chain33/util" + pty "github.com/33cn/plugin/plugin/dapp/token/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// regression tests for fork ForkTokenFinishCheck: +// finishCreate must check global token symbol uniqueness, otherwise the same +// symbol precreated by N different owners can be finished N times and each +// finish triggers GenesisInit, over-issuing N*Total of the same token. + +var dupFinishNonce int64 = 2000000 + +func dupFinishCfg(forkHeight int64) *types.Chain33Config { + cfg := types.NewChain33Config(strings.Replace(types.GetDefaultCfgstring(), "Title=\"local\"", "Title=\"chain33\"", 1)) + cfg.SetDappFork(pty.TokenX, pty.ForkTokenCheckX, 1600000) + cfg.SetDappFork(pty.TokenX, pty.ForkTokenFinishCheckX, forkHeight) + return cfg +} + +// newDupFinishExec builds a token executor with fresh state/local dbs at height 1600000. +// It does not call Init(), to avoid conflicting with the driver registration in token_test.go. +func newDupFinishExec(cfg *types.Chain33Config) (*token, dbm.KVDB, *dbm.GoMemDB) { + ety := types.LoadExecutorType(driverName) + ety.InitFuncList(types.ListMethod(&token{})) + + stateDB, _ := dbm.NewGoMemDB("dupfinish", "dupfinish", 100) + _, _, kvdb := util.CreateTestDB() + + // token-blacklist manage config, needed since ForkTokenBlackListX is active + item := &types.ConfigItem{ + Key: "mavl-manage-token-blacklist", + Value: &types.ConfigItem_Arr{Arr: &types.ArrayConfig{Value: []string{"bty"}}}, + } + stateDB.Set([]byte(item.Key), types.Encode(item)) + + // token-finisher manage config: Nodes[0] is a valid finisher + item2 := &types.ConfigItem{ + Key: "mavl-manage-token-finisher", + Value: &types.ConfigItem_Arr{Arr: &types.ArrayConfig{Value: []string{string(Nodes[0])}}}, + } + stateDB.Set([]byte(item2.Key), types.Encode(item2)) + + exec := newToken() + api := new(apimock.QueueProtocolAPI) + api.On("GetConfig", mock.Anything).Return(cfg, nil) + exec.SetAPI(api) + exec.SetStateDB(stateDB) + exec.SetLocalDB(kvdb) + exec.SetEnv(1600000, 1539918074, 1539918074) + return exec.(*token), kvdb, stateDB +} + +func dupFinishApplyReceipt(stateDB *dbm.GoMemDB, receipt *types.Receipt) { + for _, kv := range receipt.KV { + stateDB.Set(kv.Key, kv.Value) + } +} + +// dupFinishPreCreate executes one preCreate with price=0, owner and signer priv match. +func dupFinishPreCreate(t *testing.T, exec *token, stateDB *dbm.GoMemDB, + symbol, owner, priv string, total int64) error { + p := &pty.TokenPreCreate{ + Name: symbol, Symbol: symbol, Introduction: symbol, + Total: total, Price: 0, Owner: owner, + } + tx, err := types.CallCreateTransaction(pty.TokenX, "TokenPreCreate", p) + assert.Nil(t, err) + tx.Nonce = atomic.AddInt64(&dupFinishNonce, 1) + tx, err = signTx(tx, priv) + assert.Nil(t, err) + receipt, err := exec.Exec(tx, 1) + if err == nil { + dupFinishApplyReceipt(stateDB, receipt) + } + return err +} + +// dupFinishCreate finishes the token creation, signed by the finisher (Nodes[0], PrivKeyA). +func dupFinishCreate(t *testing.T, exec *token, stateDB *dbm.GoMemDB, symbol, owner string) error { + p := &pty.TokenFinishCreate{Symbol: symbol, Owner: owner} + tx, err := types.CallCreateTransaction(pty.TokenX, "TokenFinishCreate", p) + assert.Nil(t, err) + tx.Nonce = atomic.AddInt64(&dupFinishNonce, 1) + tx, err = signTx(tx, PrivKeyA) + assert.Nil(t, err) + receipt, err := exec.Exec(tx, 1) + if err == nil { + dupFinishApplyReceipt(stateDB, receipt) + } + return err +} + +// TestTokenDupFinishCreateRejected verifies with ForkTokenFinishCheck active: +// two owners precreate the same symbol, the first finish succeeds, the second +// finish is rejected and no over-issuance happens. The normal +// precreate/finish flow is not affected. +func TestTokenDupFinishCreateRejected(t *testing.T) { + cfg := dupFinishCfg(0) + exec, _, stateDB := newDupFinishExec(cfg) + symbol := "DUPCHECK" + total := int64(10000 * 1e8) + ownerA := string(Nodes[0]) + ownerB := string(Nodes[1]) + + // both owners can still precreate the same symbol (per-owner records) + err := dupFinishPreCreate(t, exec, stateDB, symbol, ownerA, PrivKeyA, total) + assert.Nil(t, err, "owner A precreate should succeed") + err = dupFinishPreCreate(t, exec, stateDB, symbol, ownerB, PrivKeyB, total) + assert.Nil(t, err, "owner B precreate same symbol should succeed") + + // first finish succeeds and issues total to owner A + err = dupFinishCreate(t, exec, stateDB, symbol, ownerA) + assert.Nil(t, err, "first finishCreate should succeed") + + // second finish of the same symbol by another owner must be rejected + err = dupFinishCreate(t, exec, stateDB, symbol, ownerB) + assert.Equal(t, pty.ErrTokenExist, err, "duplicate finishCreate must be rejected") + + // same-owner repeated finish is still rejected by the status check + err = dupFinishCreate(t, exec, stateDB, symbol, ownerA) + assert.Equal(t, pty.ErrTokenNotPrecreated, err, "repeated finish of created token rejected") + + // only owner A got the genesis amount, total issued equals declared Total + accDB, err := account.NewAccountDB(cfg, "token", symbol, stateDB) + assert.Nil(t, err) + assert.Equal(t, total, accDB.LoadAccount(ownerA).Balance) + assert.Equal(t, int64(0), accDB.LoadAccount(ownerB).Balance) + + tk, err := loadTokenDB(stateDB, symbol) + assert.Nil(t, err) + assert.Equal(t, total, tk.token.Total) +} + +// TestTokenDupFinishCreatePreFork verifies the behavior before the fork height +// stays unchanged: duplicate finishCreate of the same symbol still succeeds. +func TestTokenDupFinishCreatePreFork(t *testing.T) { + cfg := dupFinishCfg(2000000) // fork active above the test height 1600000 + exec, _, stateDB := newDupFinishExec(cfg) + symbol := "PREFORK" + total := int64(10000 * 1e8) + ownerA := string(Nodes[0]) + ownerB := string(Nodes[1]) + + err := dupFinishPreCreate(t, exec, stateDB, symbol, ownerA, PrivKeyA, total) + assert.Nil(t, err) + err = dupFinishPreCreate(t, exec, stateDB, symbol, ownerB, PrivKeyB, total) + assert.Nil(t, err) + + err = dupFinishCreate(t, exec, stateDB, symbol, ownerA) + assert.Nil(t, err) + // pre-fork behavior: no global symbol uniqueness check in finishCreate + err = dupFinishCreate(t, exec, stateDB, symbol, ownerB) + assert.Nil(t, err, "pre-fork duplicate finishCreate keeps the old behavior") + + accDB, err := account.NewAccountDB(cfg, "token", symbol, stateDB) + assert.Nil(t, err) + assert.Equal(t, total, accDB.LoadAccount(ownerA).Balance) + assert.Equal(t, total, accDB.LoadAccount(ownerB).Balance) +} diff --git a/plugin/dapp/token/executor/tokendb.go b/plugin/dapp/token/executor/tokendb.go index 586c77a570..7da41e04a2 100644 --- a/plugin/dapp/token/executor/tokendb.go +++ b/plugin/dapp/token/executor/tokendb.go @@ -251,6 +251,13 @@ func (action *tokenAction) finishCreate(tokenFinish *pty.TokenFinishCreate) (*ty return nil, pty.ErrTokenNotPrecreated } + // 全局检查token symbol唯一性,防止同一symbol被不同owner重复finish导致超发 + if cfg.IsDappFork(action.height, pty.TokenX, pty.ForkTokenFinishCheckX) { + if checkTokenExist(tokenFinish.GetSymbol(), action.db) { + return nil, pty.ErrTokenExist + } + } + approverValid := false conf := types.ConfSub(cfg, driverName) for _, approver := range conf.GStrList("tokenApprs") { diff --git a/plugin/dapp/token/testnode/token_finishcheck_test.go b/plugin/dapp/token/testnode/token_finishcheck_test.go new file mode 100644 index 0000000000..02b4036e14 --- /dev/null +++ b/plugin/dapp/token/testnode/token_finishcheck_test.go @@ -0,0 +1,176 @@ +// Copyright Fuzamei Corp. 2018 All Rights Reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package testnode + +// 链上端到端回归测试(testnode):验证 finishCreate 的全局 symbol 唯一性修复。 +// +// 单独放在 token/testnode 包:本测试通过 _ "github.com/33cn/plugin/plugin" 注册全部 dapp +// driver,而 executor 包内的 token_test.go 也会调用 Init() 注册 token driver,二者在同一 +// 测试二进制中会触发 "Register called twice for driver token" panic。 +// +// 漏洞:tokendb.go finishCreate 只按 (symbol, owner) 查 per-owner 记录并检查状态, +// 缺少像 preCreate 那样对全局 symbol 唯一性(checkTokenExist)的检查。同一 symbol +// 被两个不同 owner 先后 preCreate 后,两个 owner 都能被 finishCreate,每次 finish 都 +// 执行 GenesisInit(owner, Total),导致同一 symbol 实际发行 N * Total。 +// +// 与 token_finishcheck_test.go(函数调用级单测)的区别:单测直接 exec.Exec(tx, 1) +// 并手工 apply receipt 的 KV,绕过了真实链的 mempool -> 共识 -> executor 完整链路。 +// 本测试用 util/testnode 拉起完整链节点,走真实交易上链路径验证: +// - fork ForkTokenFinishCheck 生效后,同一 symbol 的第二次 finishCreate 被拒绝(不超发); +// - fork 高度之前,旧行为保留(第二次 finishCreate 成功,超发,用于对照复现 bug)。 + +import ( + "strings" + "testing" + + "github.com/33cn/chain33/account" + "github.com/33cn/chain33/common/crypto" + "github.com/33cn/chain33/executor" + rpctypes "github.com/33cn/chain33/rpc/types" + "github.com/33cn/chain33/types" + "github.com/33cn/chain33/util" + "github.com/33cn/chain33/util/testnode" + pty "github.com/33cn/plugin/plugin/dapp/token/types" + "github.com/stretchr/testify/require" + + _ "github.com/33cn/chain33/system" + _ "github.com/33cn/plugin/plugin" +) + +// e2eTokenCfg 构造 token fork 配置。Title 由 "local" 替换为 "chain33",否则 +// needSetForkZero 会把所有 fork 高度强制置 0,无法测试 fork 之前的行为。 +func e2eTokenCfg(forkHeight int64) *types.Chain33Config { + cfg := types.NewChain33Config(strings.Replace(types.GetDefaultCfgstring(), "Title=\"local\"", "Title=\"chain33\"", 1)) + cfg.SetDappFork(pty.TokenX, pty.ForkTokenFinishCheckX, forkHeight) + return cfg +} + +// e2eSendTokenRaw 通过 token 的 jrpc 构造未签名交易,再签名上链,等待并返回交易明细。 +func e2eSendTokenRaw(t *testing.T, mocker *testnode.Chain33Mock, method string, + param interface{}, priv crypto.PrivKey) *rpctypes.TransactionDetail { + t.Helper() + var txhex string + require.Nil(t, mocker.GetJSONC().Call(method, param, &txhex)) + hash, err := mocker.SendAndSign(priv, txhex) + require.Nil(t, err) + detail, err := mocker.WaitTx(hash) + require.Nil(t, err) + return detail +} + +// e2eSendManage 通过 manage 交易配置 token 的 manage 类配置(如 token-blacklist)。 +func e2eSendManage(t *testing.T, mocker *testnode.Chain33Mock, cfg *types.Chain33Config, + key, op, value string) { + t.Helper() + tx := util.CreateManageTx(cfg, mocker.GetHotKey(), key, op, value) + require.NotNil(t, tx) + reply, err := mocker.GetAPI().SendTx(tx) + require.Nil(t, err) + detail, err := mocker.WaitTx(reply.GetMsg()) + require.Nil(t, err) + require.Equal(t, int32(types.ExecOk), detail.Receipt.Ty, "manage %s %s failed", key, op) +} + +// e2eTokenBalance 查询 token 账户命名空间 mavl-token-- 下 addr 的余额。 +func e2eTokenBalance(t *testing.T, mocker *testnode.Chain33Mock, cfg *types.Chain33Config, + stateHash []byte, symbol, addr string) int64 { + t.Helper() + statedb := executor.NewStateDB(mocker.GetClient(), stateHash, nil, nil) + accDB, err := account.NewAccountDB(cfg, "token", symbol, statedb) + require.Nil(t, err) + return accDB.LoadAccount(addr).Balance +} + +// e2ePreCreate 两个 owner 分别 preCreate 同一 symbol(价格 0),返回最后一个交易的区块 StateHash。 +func e2ePreCreate(t *testing.T, mocker *testnode.Chain33Mock, symbol string, + total int64, ownerA, ownerB string) { + t.Helper() + preA := &pty.TokenPreCreate{Name: symbol, Symbol: symbol, Introduction: symbol, + Total: total, Price: 0, Owner: ownerA} + detail := e2eSendTokenRaw(t, mocker, "token.CreateRawTokenPreCreateTx", preA, mocker.GetGenesisKey()) + require.Equal(t, int32(types.ExecOk), detail.Receipt.Ty, "ownerA preCreate should succeed") + + preB := &pty.TokenPreCreate{Name: symbol, Symbol: symbol, Introduction: symbol, + Total: total, Price: 0, Owner: ownerB} + detail = e2eSendTokenRaw(t, mocker, "token.CreateRawTokenPreCreateTx", preB, mocker.GetGenesisKey()) + require.Equal(t, int32(types.ExecOk), detail.Receipt.Ty, "ownerB preCreate same symbol should succeed") +} + +// TestVulnE2ETokenDupFinishCreateRejected 验证 ForkTokenFinishCheck 生效后: +// 两个 owner precreate 同一 symbol,第一次 finishCreate 成功,第二次被拒绝, +// 只有第一个 owner 持有 Total,不超发。 +func TestVulnE2ETokenDupFinishCreateRejected(t *testing.T) { + cfg := e2eTokenCfg(0) // fork 高度 0,立即生效 + mocker := testnode.NewWithConfig(cfg, nil) + defer mocker.Close() + mocker.Listen() + + // 给热键(审批人/超级管理员)拨款,用于支付 manage 交易与 finishCreate 的手续费 + require.Nil(t, mocker.SendHot()) + + symbol := "DUPREJECT" + total := 10000 * types.DefaultCoinPrecision + ownerA := mocker.GetGenesisAddress() + ownerB := mocker.GetHotAddress() + + // 初始化 token-blacklist manage 配置(ForkTokenBlackListX 高度 0 生效,preCreate 必须能读到) + e2eSendManage(t, mocker, cfg, "token-blacklist", "add", "BTY") + e2ePreCreate(t, mocker, symbol, total, ownerA, ownerB) + + // 第一次 finishCreate 成功,写入全局 token 记录 + finA := &pty.TokenFinishCreate{Symbol: symbol, Owner: ownerA} + detail := e2eSendTokenRaw(t, mocker, "token.CreateRawTokenFinishTx", finA, mocker.GetHotKey()) + require.Equal(t, int32(types.ExecOk), detail.Receipt.Ty, "first finishCreate should succeed") + + // 第二次 finishCreate 同一 symbol,被全局唯一性检查拒绝 + finB := &pty.TokenFinishCreate{Symbol: symbol, Owner: ownerB} + detail = e2eSendTokenRaw(t, mocker, "token.CreateRawTokenFinishTx", finB, mocker.GetHotKey()) + require.NotEqual(t, int32(types.ExecOk), detail.Receipt.Ty, "duplicate finishCreate must be rejected") + stateHash := mocker.GetBlock(detail.Height).StateHash + + // 只有 ownerA 持有 Total,ownerB 为 0,无超发 + balA := e2eTokenBalance(t, mocker, cfg, stateHash, symbol, ownerA) + balB := e2eTokenBalance(t, mocker, cfg, stateHash, symbol, ownerB) + t.Logf("symbol=%s declared Total=%d, ownerA=%d, ownerB=%d", symbol, total, balA, balB) + require.Equal(t, total, balA) + require.Equal(t, int64(0), balB) +} + +// TestVulnE2ETokenDupFinishCreatePreFork 验证 fork 高度之前的旧行为: +// 同一 symbol 被重复 finishCreate 仍然成功,实际发行 2 * Total(超发对照,复现 bug)。 +func TestVulnE2ETokenDupFinishCreatePreFork(t *testing.T) { + cfg := e2eTokenCfg(2000000) // fork 生效高度高于测试链高度,旧行为保留 + mocker := testnode.NewWithConfig(cfg, nil) + defer mocker.Close() + mocker.Listen() + + require.Nil(t, mocker.SendHot()) + + symbol := "DUPPREFORK" + total := 10000 * types.DefaultCoinPrecision + ownerA := mocker.GetGenesisAddress() + ownerB := mocker.GetHotAddress() + + e2eSendManage(t, mocker, cfg, "token-blacklist", "add", "BTY") + e2ePreCreate(t, mocker, symbol, total, ownerA, ownerB) + + finA := &pty.TokenFinishCreate{Symbol: symbol, Owner: ownerA} + detail := e2eSendTokenRaw(t, mocker, "token.CreateRawTokenFinishTx", finA, mocker.GetHotKey()) + require.Equal(t, int32(types.ExecOk), detail.Receipt.Ty, "first finishCreate should succeed") + + // fork 之前:无全局 symbol 唯一性检查,第二次 finishCreate 仍然成功 + finB := &pty.TokenFinishCreate{Symbol: symbol, Owner: ownerB} + detail = e2eSendTokenRaw(t, mocker, "token.CreateRawTokenFinishTx", finB, mocker.GetHotKey()) + require.Equal(t, int32(types.ExecOk), detail.Receipt.Ty, "pre-fork duplicate finishCreate keeps old behavior") + stateHash := mocker.GetBlock(detail.Height).StateHash + + // 两个 owner 各持有 Total,实际发行 2 * Total(超发) + balA := e2eTokenBalance(t, mocker, cfg, stateHash, symbol, ownerA) + balB := e2eTokenBalance(t, mocker, cfg, stateHash, symbol, ownerB) + t.Logf("symbol=%s declared Total=%d, ownerA=%d, ownerB=%d, actual issued=%d (2x Total)", + symbol, total, balA, balB, balA+balB) + require.Equal(t, total, balA) + require.Equal(t, total, balB) +} diff --git a/plugin/dapp/token/types/const.go b/plugin/dapp/token/types/const.go index ec4ad28277..3525c5c497 100644 --- a/plugin/dapp/token/types/const.go +++ b/plugin/dapp/token/types/const.go @@ -51,6 +51,8 @@ var ( ForkTokenCheckX = "ForkTokenCheck" //ForkTokenEvm token asset can be transfer by evm ForkTokenEvm = "ForkTokenEvm" + // ForkTokenFinishCheckX fork token finish create check global symbol uniqueness + ForkTokenFinishCheckX = "ForkTokenFinishCheck" ) const ( diff --git a/plugin/dapp/token/types/types.go b/plugin/dapp/token/types/types.go index fe06448c99..84c572c823 100644 --- a/plugin/dapp/token/types/types.go +++ b/plugin/dapp/token/types/types.go @@ -30,6 +30,7 @@ func InitFork(cfg *types.Chain33Config) { cfg.RegisterDappFork(TokenX, ForkTokenSymbolWithNumberX, 0) cfg.RegisterDappFork(TokenX, ForkTokenCheckX, 0) cfg.RegisterDappFork(TokenX, ForkTokenEvm, 0) + cfg.RegisterDappFork(TokenX, ForkTokenFinishCheckX, 0) } //InitExecutor ...