diff --git a/sdk/ton/timelock_configurer.go b/sdk/ton/timelock_configurer.go index 1de36430..81e34652 100644 --- a/sdk/ton/timelock_configurer.go +++ b/sdk/ton/timelock_configurer.go @@ -9,10 +9,11 @@ import ( "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/ton/wallet" - "github.com/xssnick/tonutils-go/tvm/cell" + "github.com/smartcontractkit/chainlink-ton/cciplib/ton/tlbe" "github.com/smartcontractkit/chainlink-ton/cciplib/ton/tvm" "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/chainlink-ton/pkg/bindings/lib/access/rbac" "github.com/smartcontractkit/chainlink-ton/pkg/bindings/mcms/timelock" "github.com/smartcontractkit/mcms/sdk" @@ -42,6 +43,26 @@ func NewTimelockConfigurer(w *wallet.Wallet, amount tlb.Coins, opts ...TimelockC type TimelockConfigurerOption func(*TimelockConfigurer) +// resolveQueryID returns a deterministic QueryID for prepared (skipSend) transactions, +// or a random QueryID for direct-send transactions. +func (c *TimelockConfigurer) resolveQueryID(dst *address.Address, operation string, msg any) (uint64, error) { + if c.skipSend { + body, err := tlb.ToCell(msg) + if err != nil { + return 0, fmt.Errorf("failed to encode %s body for query ID: %w", operation, err) + } + + return deterministicPreparedQueryID(dst, operation, body), nil + } + + qID, err := tvm.RandomQueryID() + if err != nil { + return 0, fmt.Errorf("failed to generate random query ID: %w", err) + } + + return qID, nil +} + func WithDoNotSendTimelockInstructionsOnChain() TimelockConfigurerOption { return func(c *TimelockConfigurer) { c.skipSend = true @@ -65,22 +86,13 @@ func (c *TimelockConfigurer) UpdateDelay( msg := timelock.UpdateDelay{ NewDelay: uint32(newDelay), } - var body *cell.Cell - if c.skipSend { - body, err = tlb.ToCell(msg) - if err != nil { - return types.TransactionResult{}, fmt.Errorf("failed to encode UpdateDelay body: %w", err) - } - msg.QueryID = deterministicPreparedQueryID(dstAddr, "RBACTimelock:UpdateDelay", body) - } else { - msg.QueryID, err = tvm.RandomQueryID() - if err != nil { - return types.TransactionResult{}, fmt.Errorf("failed to generate random query ID: %w", err) - } + msg.QueryID, err = c.resolveQueryID(dstAddr, "RBACTimelock:UpdateDelay", msg) + if err != nil { + return types.TransactionResult{}, err } - body, err = tlb.ToCell(msg) + body, err := tlb.ToCell(msg) if err != nil { return types.TransactionResult{}, fmt.Errorf("failed to encode UpdateDelay body: %w", err) } @@ -106,12 +118,61 @@ func (c *TimelockConfigurer) UpdateDelay( }) } -// GrantRole grants a timelock role to an address. +// GrantRole sends the RBACTimelock GrantRole message to the given timelock +// address, granting role to targetAddress. func (c *TimelockConfigurer) GrantRole( ctx context.Context, timelockAddress string, role sdk.TimelockRole, targetAddress string, ) (types.TransactionResult, error) { - panic("not implemented") + dstAddr, err := address.ParseAddr(timelockAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("invalid timelock address: %w", err) + } + + account, err := address.ParseAddr(targetAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("invalid target address: %w", err) + } + + roleHash, err := TimelockRoleHash(role) + if err != nil { + return types.TransactionResult{}, err + } + + msg := rbac.GrantRole{ + Role: tlbe.NewUint256(roleHash), + Account: account, + } + + msg.QueryID, err = c.resolveQueryID(dstAddr, "RBACTimelock:GrantRole", msg) + if err != nil { + return types.TransactionResult{}, err + } + + body, err := tlb.ToCell(msg) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("failed to encode GrantRole body: %w", err) + } + + if c.skipSend { + tx, err := NewTransaction(dstAddr, body.ToBuilder().ToSlice(), c.amount.Nano(), bindings.ShortTimelock, nil, bindings.TypeTimelock, []string{bindings.ShortTimelock, "GrantRole"}) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("error encoding transaction: %w", err) + } + + return types.TransactionResult{ + Hash: "", + ChainFamily: chainsel.FamilyTon, + RawData: tx, + }, nil + } + + return SendTx(ctx, TxOpts{ + Wallet: c.wallet, + DstAddr: dstAddr, + Amount: c.amount, + Body: body, + }) } diff --git a/sdk/ton/timelock_configurer_test.go b/sdk/ton/timelock_configurer_test.go index a1eb29a9..92e5430a 100644 --- a/sdk/ton/timelock_configurer_test.go +++ b/sdk/ton/timelock_configurer_test.go @@ -7,15 +7,18 @@ import ( "testing" "github.com/smartcontractkit/chainlink-ton/cciplib/ton/tvm" + "github.com/smartcontractkit/chainlink-ton/pkg/bindings/lib/access/rbac" "github.com/smartcontractkit/chainlink-ton/pkg/bindings/mcms/timelock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/ton" "github.com/xssnick/tonutils-go/tvm/cell" "github.com/smartcontractkit/mcms/internal/testutils/chaintest" + "github.com/smartcontractkit/mcms/sdk" mcmston "github.com/smartcontractkit/mcms/sdk/ton" ton_mocks "github.com/smartcontractkit/mcms/sdk/ton/mocks" "github.com/smartcontractkit/mcms/types" @@ -148,3 +151,162 @@ func TestTimelockConfigurer_UpdateDelay(t *testing.T) { }) } } + +func TestTimelockConfigurer_GrantRole(t *testing.T) { + t.Parallel() + + const validTimelockAddr = "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8" + validTargetAddr := address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8") + + tests := []struct { + name string + timelockAddress string + role sdk.TimelockRole + targetAddress string + options []mcmston.TimelockConfigurerOption + mockSetup func(m *ton_mocks.TonAPI) + wantHash string + wantErr string + wantPrepared bool + }{ + { + name: "success", + timelockAddress: validTimelockAddr, + role: sdk.TimelockRoleProposer, + targetAddress: validTargetAddr.String(), + mockSetup: func(m *ton_mocks.TonAPI) { + m.EXPECT().CurrentMasterchainInfo(mock.Anything). + Return(&ton.BlockIDExt{}, nil) + + apiw := ton_mocks.NewAPIClientWrapped(t) + apiw.EXPECT().GetAccount(mock.Anything, mock.Anything, mock.Anything). + Return(&tlb.Account{}, nil) + apiw.EXPECT().RunGetMethod(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(ton.NewExecutionResult([]any{big.NewInt(5)}), nil) + + m.EXPECT().WaitForBlock(mock.Anything).Return(apiw) + m.EXPECT().SendExternalMessageWaitTransaction(mock.Anything, mock.Anything). + Return(&tlb.Transaction{Hash: []byte{0xde, 0xad, 0xbe, 0xef}}, &ton.BlockIDExt{}, []byte{}, nil) + }, + wantHash: "deadbeef", + }, + { + name: "success - WithDoNotSendTimelockInstructionsOnChain option", + timelockAddress: validTimelockAddr, + role: sdk.TimelockRoleProposer, + targetAddress: validTargetAddr.String(), + options: []mcmston.TimelockConfigurerOption{ + mcmston.WithDoNotSendTimelockInstructionsOnChain(), + }, + mockSetup: func(m *ton_mocks.TonAPI) {}, + wantPrepared: true, + }, + { + name: "success - admin role", + timelockAddress: validTimelockAddr, + role: sdk.TimelockRoleAdmin, + targetAddress: validTargetAddr.String(), + options: []mcmston.TimelockConfigurerOption{ + mcmston.WithDoNotSendTimelockInstructionsOnChain(), + }, + mockSetup: func(m *ton_mocks.TonAPI) {}, + wantPrepared: true, + }, + { + name: "invalid timelock address", + timelockAddress: "not-a-valid-ton-address", + role: sdk.TimelockRoleProposer, + targetAddress: validTargetAddr.String(), + mockSetup: func(m *ton_mocks.TonAPI) {}, + wantErr: "invalid timelock address", + }, + { + name: "invalid target address", + timelockAddress: validTimelockAddr, + role: sdk.TimelockRoleProposer, + targetAddress: "not-a-valid-ton-address", + mockSetup: func(m *ton_mocks.TonAPI) {}, + wantErr: "invalid target address", + }, + { + name: "invalid timelock role", + timelockAddress: validTimelockAddr, + role: sdk.TimelockRole(99), + targetAddress: validTargetAddr.String(), + mockSetup: func(m *ton_mocks.TonAPI) {}, + wantErr: "invalid timelock role", + }, + { + name: "send transaction fails", + timelockAddress: validTimelockAddr, + role: sdk.TimelockRoleProposer, + targetAddress: validTargetAddr.String(), + mockSetup: func(m *ton_mocks.TonAPI) { + m.EXPECT().CurrentMasterchainInfo(mock.Anything). + Return(&ton.BlockIDExt{}, nil) + + apiw := ton_mocks.NewAPIClientWrapped(t) + apiw.EXPECT().GetAccount(mock.Anything, mock.Anything, mock.Anything). + Return(&tlb.Account{}, nil) + apiw.EXPECT().RunGetMethod(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(ton.NewExecutionResult([]any{big.NewInt(5)}), nil) + + m.EXPECT().WaitForBlock(mock.Anything).Return(apiw) + m.EXPECT().SendExternalMessageWaitTransaction(mock.Anything, mock.Anything). + Return(nil, nil, nil, errors.New("boom")) + }, + wantErr: "failed to send transaction", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + api := ton_mocks.NewTonAPI(t) + chainID := chaintest.Chain7TONID + walletOperator := must(tvm.NewRandomV5R1TestWallet(api, chainID)) + + tt.mockSetup(api) + + configurer := mcmston.NewTimelockConfigurer(walletOperator, tlb.MustFromTON("0.1"), tt.options...) + result, err := configurer.GrantRole(t.Context(), tt.timelockAddress, tt.role, tt.targetAddress) + + if tt.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, result.Hash) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantHash, result.Hash) + if tt.wantPrepared { + tx, ok := result.RawData.(types.Transaction) + require.True(t, ok) + assert.Equal(t, "RBACTimelock", tx.ContractType) + assert.Equal(t, []string{"RBACTimelock", "GrantRole"}, tx.Tags) + body := must(cell.FromBOC(tx.Data)) + var msg rbac.GrantRole + require.NoError(t, tlb.LoadFromCell(&msg, body.BeginParse())) + + roleHash, err := mcmston.TimelockRoleHash(tt.role) + require.NoError(t, err) + assert.Equal(t, roleHash, msg.Role.Value()) + assert.Equal(t, address.MustParseAddr(tt.targetAddress), msg.Account) + assert.NotZero(t, msg.QueryID) + + result2, err := configurer.GrantRole(t.Context(), tt.timelockAddress, tt.role, tt.targetAddress) + require.NoError(t, err) + tx2, ok := result2.RawData.(types.Transaction) + require.True(t, ok) + body2 := must(cell.FromBOC(tx2.Data)) + var msg2 rbac.GrantRole + require.NoError(t, tlb.LoadFromCell(&msg2, body2.BeginParse())) + assert.Equal(t, tx.Data, tx2.Data) + assert.Equal(t, msg.QueryID, msg2.QueryID) + } + }) + } +} diff --git a/sdk/ton/timelock_role.go b/sdk/ton/timelock_role.go new file mode 100644 index 00000000..5b1e787e --- /dev/null +++ b/sdk/ton/timelock_role.go @@ -0,0 +1,28 @@ +package ton + +import ( + "fmt" + "math/big" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings/mcms/timelock" + + "github.com/smartcontractkit/mcms/sdk" +) + +var timelockRoleHashes = map[sdk.TimelockRole]*big.Int{ + sdk.TimelockRoleAdmin: timelock.RoleAdmin, + sdk.TimelockRoleBypasser: timelock.RoleBypasser, + sdk.TimelockRoleCanceller: timelock.RoleCanceller, + sdk.TimelockRoleExecutor: timelock.RoleExecutor, + sdk.TimelockRoleProposer: timelock.RoleProposer, +} + +// TimelockRoleHash returns the RBACTimelock AccessControl role hash for role. +func TimelockRoleHash(role sdk.TimelockRole) (*big.Int, error) { + hash, ok := timelockRoleHashes[role] + if !ok { + return nil, fmt.Errorf("invalid timelock role: %d", role) + } + + return hash, nil +}