diff --git a/.dockerignore b/.dockerignore index 6925215bc..6d1c56583 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,14 +13,20 @@ **/*.pb.go .git **/.gradle + # Doc site is 100s of MB doc-site/ + # Zeto ZKP is 1GB domains/zeto/zkp +# Other folders not needed for the Docker build +example/ +sdk/ + # The operator has its own docker build (with its own .dockerignore), so we don't want to rebuild the whole # Paladin docker every time you're re-running the operator/test. # So we only install enough to keep the build happy. operator/** !operator/go.mod -!operator/build.gradle \ No newline at end of file +!operator/build.gradle diff --git a/.vscode/launch.json b/.vscode/launch.json index 5a57a6c40..30121c00c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,6 +15,17 @@ "type": "node", "cwd": "${workspaceFolder}/example/bond" }, + { + "name": "Lock example: run", + "request": "launch", + "runtimeArgs": [ + "run", + "start" + ], + "runtimeExecutable": "npm", + "type": "node", + "cwd": "${workspaceFolder}/example/lock" + }, { "name": "Run Controller", "type": "go", diff --git a/core/go/internal/components/statemgr.go b/core/go/internal/components/statemgr.go index 2f268a9bd..3ef907f8a 100644 --- a/core/go/internal/components/statemgr.go +++ b/core/go/internal/components/statemgr.go @@ -96,6 +96,9 @@ type DomainContext interface { // The dbTX is passed in to allow re-use of a connection during read operations. FindAvailableStates(dbTX *gorm.DB, schemaID tktypes.Bytes32, query *query.QueryJSON) (Schema, []*pldapi.State, error) + // GetStates retrieves a set of states by ID + GetStates(dbTX *gorm.DB, schemaID tktypes.Bytes32, ids []string) (Schema, []*pldapi.State, error) + // Return a snapshot of all currently known state locks ExportSnapshot() ([]byte, error) diff --git a/core/go/internal/domainmgr/domain.go b/core/go/internal/domainmgr/domain.go index 4c6f25a85..138412acb 100644 --- a/core/go/internal/domainmgr/domain.go +++ b/core/go/internal/domainmgr/domain.go @@ -279,6 +279,26 @@ func (d *domain) Configuration() *prototk.DomainConfig { return d.config } +func toProtoStates(states []*pldapi.State) []*prototk.StoredState { + pbStates := make([]*prototk.StoredState, len(states)) + for i, s := range states { + pbStates[i] = &prototk.StoredState{ + Id: s.ID.String(), + SchemaId: s.Schema.String(), + CreatedAt: s.Created.UnixNano(), + DataJson: string(s.Data), + Locks: []*prototk.StateLock{}, + } + for _, l := range s.Locks { + pbStates[i].Locks = append(pbStates[i].Locks, &prototk.StateLock{ + Type: mapStateLockType(l.Type.V()), + Transaction: l.Transaction.String(), + }) + } + } + return pbStates +} + // Domain callback to query the state store func (d *domain) FindAvailableStates(ctx context.Context, req *prototk.FindAvailableStatesRequest) (*prototk.FindAvailableStatesResponse, error) { c, err := d.checkInFlight(ctx, req.StateQueryContext) @@ -306,24 +326,8 @@ func (d *domain) FindAvailableStates(ctx context.Context, req *prototk.FindAvail return nil, err } - pbStates := make([]*prototk.StoredState, len(states)) - for i, s := range states { - pbStates[i] = &prototk.StoredState{ - Id: s.ID.String(), - SchemaId: s.Schema.String(), - CreatedAt: s.Created.UnixNano(), - DataJson: string(s.Data), - Locks: []*prototk.StateLock{}, - } - for _, l := range s.Locks { - pbStates[i].Locks = append(pbStates[i].Locks, &prototk.StateLock{ - Type: mapStateLockType(l.Type.V()), - Transaction: l.Transaction.String(), - }) - } - } return &prototk.FindAvailableStatesResponse{ - States: pbStates, + States: toProtoStates(states), }, nil } @@ -771,3 +775,55 @@ func (d *domain) BuildDomainReceipt(ctx context.Context, dbTX *gorm.DB, txID uui } return tktypes.RawJSON(res.ReceiptJson), nil } + +func (d *domain) SendTransaction(ctx context.Context, tx *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) { + txType := pldapi.TransactionTypePrivate + if tx.Transaction.Type == prototk.TransactionInput_PUBLIC { + txType = pldapi.TransactionTypePublic + } + contractAddress, err := tktypes.ParseEthAddress(tx.Transaction.ContractAddress) + if err != nil { + return nil, err + } + var functionABI abi.Entry + if err = json.Unmarshal([]byte(tx.Transaction.FunctionAbiJson), &functionABI); err != nil { + return nil, err + } + + id, err := d.dm.txManager.SendTransaction(ctx, &pldapi.TransactionInput{ + TransactionBase: pldapi.TransactionBase{ + Type: txType.Enum(), + From: tx.Transaction.From, + To: contractAddress, + Data: tktypes.RawJSON(tx.Transaction.ParamsJson), + }, + ABI: abi.ABI{&functionABI}, + }) + if err != nil { + return nil, err + } + return &prototk.SendTransactionResponse{Id: id.String()}, nil +} + +func (d *domain) LocalNodeName(ctx context.Context, req *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) { + return &prototk.LocalNodeNameResponse{ + Name: d.dm.transportMgr.LocalNodeName(), + }, nil +} + +func (d *domain) GetStates(ctx context.Context, req *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) { + c, err := d.checkInFlight(ctx, req.StateQueryContext) + if err != nil { + return nil, err + } + + schemaID, err := tktypes.ParseBytes32(req.SchemaId) + if err != nil { + return nil, i18n.WrapError(ctx, err, msgs.MsgDomainInvalidSchemaID, req.SchemaId) + } + + _, states, err := c.dCtx.GetStates(c.dbTX, schemaID, req.StateIds) + return &prototk.GetStatesResponse{ + States: toProtoStates(states), + }, err +} diff --git a/core/go/internal/domainmgr/domain_test.go b/core/go/internal/domainmgr/domain_test.go index 585dfa20e..c9d40503a 100644 --- a/core/go/internal/domainmgr/domain_test.go +++ b/core/go/internal/domainmgr/domain_test.go @@ -287,7 +287,7 @@ func TestDomainInitStates(t *testing.T) { } func mockUpsertABIOk(mc *mockComponents) { mc.txManager.On("UpsertABI", mock.Anything, mock.Anything, mock.Anything).Return(func() {}, &pldapi.StoredABI{ - Hash: tktypes.Bytes32(tktypes.RandBytes(32)), + Hash: tktypes.RandBytes32(), }, nil) } @@ -496,7 +496,7 @@ func TestDomainFindAvailableStatesFail(t *testing.T) { }) defer done() - schemaID := tktypes.Bytes32(tktypes.RandBytes(32)) + schemaID := tktypes.RandBytes32() td.mdc.On("FindAvailableStates", mock.Anything, schemaID, mock.Anything).Return(nil, nil, fmt.Errorf("pop")) assert.Nil(t, td.d.initError.Load()) @@ -510,7 +510,7 @@ func TestDomainFindAvailableStatesFail(t *testing.T) { func storeTestState(t *testing.T, td *testDomainContext, txID uuid.UUID, amount *ethtypes.HexInteger) *fakeState { state := &fakeState{ - Salt: tktypes.Bytes32(tktypes.RandBytes(32)), + Salt: tktypes.RandBytes32(), Owner: tktypes.EthAddress(tktypes.RandBytes(20)), Amount: amount, } @@ -1022,6 +1022,55 @@ func TestRecoverSignerFailCases(t *testing.T) { assert.Regexp(t, "PD011638", err) } +func TestSendTransactionFailCases(t *testing.T) { + td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas()) + defer done() + + _, err := td.d.SendTransaction(td.ctx, &prototk.SendTransactionRequest{ + Transaction: &prototk.TransactionInput{ + ContractAddress: "badnotgood", + FunctionAbiJson: `{}`, + ParamsJson: `{}`, + }, + }) + require.ErrorContains(t, err, "bad address") + + _, err = td.d.SendTransaction(td.ctx, &prototk.SendTransactionRequest{ + Transaction: &prototk.TransactionInput{ + ContractAddress: "0x05d936207F04D81a85881b72A0D17854Ee8BE45A", + FunctionAbiJson: `bad`, + ParamsJson: `{}`, + }, + }) + require.ErrorContains(t, err, "invalid character") +} + +func TestGetStatesFailCases(t *testing.T) { + td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas()) + defer done() + + _, err := td.d.GetStates(td.ctx, &prototk.GetStatesRequest{ + StateQueryContext: "bad", + }) + require.ErrorContains(t, err, "PD011649") + + _, err = td.d.GetStates(td.ctx, &prototk.GetStatesRequest{ + StateQueryContext: td.c.id, + SchemaId: "bad", + }) + require.ErrorContains(t, err, "PD011641") + + schemaID := tktypes.Bytes32(tktypes.RandBytes(32)) + td.mdc.On("GetStates", mock.Anything, schemaID, []string{"id1"}).Return(nil, nil, fmt.Errorf("pop")) + + _, err = td.d.GetStates(td.ctx, &prototk.GetStatesRequest{ + StateQueryContext: td.c.id, + SchemaId: schemaID.String(), + StateIds: []string{"id1"}, + }) + require.EqualError(t, err, "pop") +} + func TestMapStateLockType(t *testing.T) { for _, pldType := range pldapi.StateLockType("").Options() { assert.NotNil(t, mapStateLockType(pldapi.StateLockType(pldType))) diff --git a/core/go/internal/domainmgr/event_indexer_test.go b/core/go/internal/domainmgr/event_indexer_test.go index 0843ea271..e3c383bff 100644 --- a/core/go/internal/domainmgr/event_indexer_test.go +++ b/core/go/internal/domainmgr/event_indexer_test.go @@ -205,7 +205,7 @@ func TestHandleEventBatch(t *testing.T) { stateConfirmed := tktypes.RandHex(32) stateInfo := tktypes.RandHex(32) fakeHash1 := tktypes.RandHex(32) - fakeSchema := tktypes.Bytes32(tktypes.RandBytes(32)) + fakeSchema := tktypes.RandBytes32() event1 := &pldapi.EventWithData{ Address: *contract1, IndexedEvent: &pldapi.IndexedEvent{ @@ -439,7 +439,7 @@ func TestHandleEventBatchRegistrationError(t *testing.T) { mp.Mock.ExpectExec("INSERT.*private_smart_contracts").WillReturnError(fmt.Errorf("pop")) registrationData := &event_PaladinRegisterSmartContract_V0{ - TXId: tktypes.Bytes32(tktypes.RandBytes(32)), + TXId: tktypes.RandBytes32(), } registrationDataJSON, err := json.Marshal(registrationData) require.NoError(t, err) diff --git a/core/go/internal/domainmgr/private_smart_contract_test.go b/core/go/internal/domainmgr/private_smart_contract_test.go index 950401d39..26e2dfeea 100644 --- a/core/go/internal/domainmgr/private_smart_contract_test.go +++ b/core/go/internal/domainmgr/private_smart_contract_test.go @@ -522,6 +522,51 @@ func TestRecoverSignature(t *testing.T) { assert.Equal(t, kp.Address.String(), res.Verifier) } +func TestSendTransaction(t *testing.T) { + txID := uuid.New() + td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas(), func(mc *mockComponents) { + mc.txManager.On("SendTransaction", mock.Anything, mock.Anything).Return(&txID, nil) + }) + defer done() + assert.Nil(t, td.d.initError.Load()) + + _, err := td.d.SendTransaction(td.ctx, &prototk.SendTransactionRequest{ + Transaction: &prototk.TransactionInput{ + ContractAddress: "0x05d936207F04D81a85881b72A0D17854Ee8BE45A", + FunctionAbiJson: `{}`, + ParamsJson: `{}`, + }, + }) + require.NoError(t, err) +} + +func TestSendTransactionFail(t *testing.T) { + td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas(), func(mc *mockComponents) { + mc.txManager.On("SendTransaction", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("pop")) + }) + defer done() + assert.Nil(t, td.d.initError.Load()) + + _, err := td.d.SendTransaction(td.ctx, &prototk.SendTransactionRequest{ + Transaction: &prototk.TransactionInput{ + ContractAddress: "0x05d936207F04D81a85881b72A0D17854Ee8BE45A", + FunctionAbiJson: `{}`, + ParamsJson: `{}`, + }, + }) + require.EqualError(t, err, "pop") +} + +func TestLocalNodeName(t *testing.T) { + td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas()) + defer done() + assert.Nil(t, td.d.initError.Load()) + + res, err := td.d.LocalNodeName(td.ctx, &prototk.LocalNodeNameRequest{}) + require.NoError(t, err) + assert.Equal(t, "node1", res.Name) +} + func TestDomainInitTransactionMissingInput(t *testing.T) { td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas()) defer done() @@ -606,13 +651,13 @@ func TestFullTransactionRealDBOK(t *testing.T) { state4 := storeTestState(t, td, ptx.ID, ethtypes.NewHexInteger64(4444444)) state5 := &fakeState{ - Salt: tktypes.Bytes32(tktypes.RandBytes(32)), + Salt: tktypes.RandBytes32(), Owner: tktypes.EthAddress(tktypes.RandBytes(20)), Amount: ethtypes.NewHexInteger64(5555555), } state6 := &fakeState{ - Salt: tktypes.Bytes32(tktypes.RandBytes(32)), + Salt: tktypes.RandBytes32(), Owner: tktypes.EthAddress(tktypes.RandBytes(20)), Amount: ethtypes.NewHexInteger64(6666666), } @@ -957,7 +1002,7 @@ func TestDomainWritePotentialStatesBadSchema(t *testing.T) { func TestDomainWritePotentialStatesFail(t *testing.T) { schema := componentmocks.NewSchema(t) - schemaID := tktypes.Bytes32(tktypes.RandBytes(32)) + schemaID := tktypes.RandBytes32() schema.On("ID").Return(schemaID) schema.On("Signature").Return("schema1_signature") td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas(schema), mockBlockHeight) @@ -975,7 +1020,7 @@ func TestDomainWritePotentialStatesFail(t *testing.T) { func TestDomainWritePotentialStatesBadID(t *testing.T) { schema := componentmocks.NewSchema(t) - schemaID := tktypes.Bytes32(tktypes.RandBytes(32)) + schemaID := tktypes.RandBytes32() schema.On("ID").Return(schemaID) schema.On("Signature").Return("schema1_signature") td, done := newTestDomain(t, false, goodDomainConf(), mockSchemas(schema), mockBlockHeight) diff --git a/core/go/internal/plugins/domains.go b/core/go/internal/plugins/domains.go index 0cdd3af7a..512ab0f37 100644 --- a/core/go/internal/plugins/domains.go +++ b/core/go/internal/plugins/domains.go @@ -98,6 +98,33 @@ func (br *domainBridge) RequestReply(ctx context.Context, reqMsg plugintk.Plugin } }, ) + case *prototk.DomainMessage_SendTransaction: + return callManagerImpl(ctx, req.SendTransaction, + br.manager.SendTransaction, + func(resMsg *prototk.DomainMessage, res *prototk.SendTransactionResponse) { + resMsg.ResponseToDomain = &prototk.DomainMessage_SendTransactionRes{ + SendTransactionRes: res, + } + }, + ) + case *prototk.DomainMessage_LocalNodeName: + return callManagerImpl(ctx, req.LocalNodeName, + br.manager.LocalNodeName, + func(resMsg *prototk.DomainMessage, res *prototk.LocalNodeNameResponse) { + resMsg.ResponseToDomain = &prototk.DomainMessage_LocalNodeNameRes{ + LocalNodeNameRes: res, + } + }, + ) + case *prototk.DomainMessage_GetStates: + return callManagerImpl(ctx, req.GetStates, + br.manager.GetStates, + func(resMsg *prototk.DomainMessage, res *prototk.GetStatesResponse) { + resMsg.ResponseToDomain = &prototk.DomainMessage_GetStatesRes{ + GetStatesRes: res, + } + }, + ) default: return nil, i18n.NewError(ctx, msgs.MsgPluginBadRequestBody, req) } diff --git a/core/go/internal/plugins/domains_test.go b/core/go/internal/plugins/domains_test.go index faa64d25f..c0cca5b36 100644 --- a/core/go/internal/plugins/domains_test.go +++ b/core/go/internal/plugins/domains_test.go @@ -44,6 +44,9 @@ type testDomainManager struct { encodeData func(context.Context, *prototk.EncodeDataRequest) (*prototk.EncodeDataResponse, error) decodeData func(context.Context, *prototk.DecodeDataRequest) (*prototk.DecodeDataResponse, error) recoverSigner func(context.Context, *prototk.RecoverSignerRequest) (*prototk.RecoverSignerResponse, error) + sendTransaction func(context.Context, *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) + localNodeName func(context.Context, *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) + getStates func(context.Context, *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) } func (tp *testDomainManager) FindAvailableStates(ctx context.Context, req *prototk.FindAvailableStatesRequest) (*prototk.FindAvailableStatesResponse, error) { @@ -62,6 +65,18 @@ func (tp *testDomainManager) RecoverSigner(ctx context.Context, req *prototk.Rec return tp.recoverSigner(ctx, req) } +func (tp *testDomainManager) SendTransaction(ctx context.Context, req *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) { + return tp.sendTransaction(ctx, req) +} + +func (tp *testDomainManager) LocalNodeName(ctx context.Context, req *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) { + return tp.localNodeName(ctx, req) +} + +func (tp *testDomainManager) GetStates(ctx context.Context, req *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) { + return tp.getStates(ctx, req) +} + func domainConnectFactory(ctx context.Context, client prototk.PluginControllerClient) (grpc.BidiStreamingClient[prototk.DomainMessage, prototk.DomainMessage], error) { return client.ConnectDomain(context.Background()) } @@ -275,6 +290,26 @@ func TestDomainRequestsOK(t *testing.T) { }, nil } + tdm.sendTransaction = func(ctx context.Context, str *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) { + assert.Equal(t, str.Transaction.From, "user1") + return &prototk.SendTransactionResponse{ + Id: "tx1", + }, nil + } + + tdm.localNodeName = func(ctx context.Context, lnr *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) { + return &prototk.LocalNodeNameResponse{ + Name: "node1", + }, nil + } + + tdm.getStates = func(ctx context.Context, gsr *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) { + assert.Equal(t, "schema1", gsr.SchemaId) + return &prototk.GetStatesResponse{ + States: []*prototk.StoredState{{}}, + }, nil + } + ctx, pc, done := newTestDomainPluginManager(t, &testManagers{ testDomainManager: tdm, }) @@ -417,6 +452,24 @@ func TestDomainRequestsOK(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, "some verifier", string(rsr.Verifier)) + + str, err := callbacks.SendTransaction(ctx, &prototk.SendTransactionRequest{ + Transaction: &prototk.TransactionInput{ + From: "user1", + }, + }) + require.NoError(t, err) + assert.Equal(t, "tx1", str.Id) + + lnr, err := callbacks.LocalNodeName(ctx, &prototk.LocalNodeNameRequest{}) + require.NoError(t, err) + assert.Equal(t, "node1", lnr.Name) + + gsr, err := callbacks.GetStates(ctx, &prototk.GetStatesRequest{ + SchemaId: "schema1", + }) + require.NoError(t, err) + assert.Len(t, gsr.States, 1) } func TestDomainRegisterFail(t *testing.T) { diff --git a/core/go/internal/privatetxnmgr/private_txn_mgr_test.go b/core/go/internal/privatetxnmgr/private_txn_mgr_test.go index f3065a324..e06c0062b 100644 --- a/core/go/internal/privatetxnmgr/private_txn_mgr_test.go +++ b/core/go/internal/privatetxnmgr/private_txn_mgr_test.go @@ -259,7 +259,7 @@ func TestPrivateTxManagerSimpleTransaction(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -307,8 +307,8 @@ func TestPrivateTxManagerSimpleTransaction(t *testing.T) { mocks.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) @@ -441,7 +441,7 @@ func TestPrivateTxManagerSimplePreparedTransaction(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -489,8 +489,8 @@ func TestPrivateTxManagerSimplePreparedTransaction(t *testing.T) { mocks.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) @@ -622,7 +622,7 @@ func TestPrivateTxManagerMultipleSignature(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -712,8 +712,8 @@ func TestPrivateTxManagerMultipleSignature(t *testing.T) { mocks.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) @@ -841,7 +841,7 @@ func TestPrivateTxManagerRemoteNotaryEndorser(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -899,8 +899,8 @@ func TestPrivateTxManagerRemoteNotaryEndorser(t *testing.T) { remoteEngineMocks.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) @@ -1027,7 +1027,7 @@ func TestPrivateTxManagerRemoteNotaryEndorserRetry(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -1093,8 +1093,8 @@ func TestPrivateTxManagerRemoteNotaryEndorserRetry(t *testing.T) { remoteEngineMocks.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) @@ -1225,7 +1225,7 @@ func TestPrivateTxManagerEndorsementGroup(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -1385,7 +1385,7 @@ func TestPrivateTxManagerEndorsementGroupDynamicCoordinator(t *testing.T) { InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -1601,7 +1601,7 @@ func TestPrivateTxManagerEndorsementGroupDynamicCoordinatorRangeBoundaryHandover InputStates: []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, }, @@ -1817,7 +1817,7 @@ func TestPrivateTxManagerDependantTransactionEndorsedOutOfOrder(t *testing.T) { states := []*components.FullState{ { ID: tktypes.RandBytes(32), - Schema: tktypes.Bytes32(tktypes.RandBytes(32)), + Schema: tktypes.RandBytes32(), Data: tktypes.JSONString("foo"), }, } @@ -1905,8 +1905,8 @@ func TestPrivateTxManagerDependantTransactionEndorsedOutOfOrder(t *testing.T) { aliceEngineMocks.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) @@ -2648,8 +2648,8 @@ func (m *dependencyMocks) mockForSubmitter(t *testing.T, transactionID *uuid.UUI m.domainSmartContract.On("PrepareTransaction", mock.Anything, mock.Anything, mock.MatchedBy(privateTransactionMatcher(*transactionID))).Return(nil).Run( func(args mock.Arguments) { cv, err := testABI[0].Inputs.ParseExternalData(map[string]any{ - "inputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, - "outputs": []any{tktypes.Bytes32(tktypes.RandBytes(32))}, + "inputs": []any{tktypes.RandBytes32()}, + "outputs": []any{tktypes.RandBytes32()}, "data": "0xfeedbeef", }) require.NoError(t, err) diff --git a/core/go/internal/privatetxnmgr/state_distribution_builder_test.go b/core/go/internal/privatetxnmgr/state_distribution_builder_test.go index 9ab43b36e..26ff01cb5 100644 --- a/core/go/internal/privatetxnmgr/state_distribution_builder_test.go +++ b/core/go/internal/privatetxnmgr/state_distribution_builder_test.go @@ -38,9 +38,9 @@ func newTestStateDistributionBuilder(t *testing.T, tx *components.PrivateTransac } func TestStateDistributionBuilderAllSenderNoNullifiers(t *testing.T) { - schema1ID := tktypes.Bytes32(tktypes.RandBytes(32)) + schema1ID := tktypes.RandBytes32() state1ID := tktypes.HexBytes(tktypes.RandBytes(32)) - schema2ID := tktypes.Bytes32(tktypes.RandBytes(32)) + schema2ID := tktypes.RandBytes32() state2ID := tktypes.HexBytes(tktypes.RandBytes(32)) contractAddr := *tktypes.RandAddress() ctx, sd := newTestStateDistributionBuilder(t, &components.PrivateTransaction{ @@ -96,7 +96,7 @@ func TestStateDistributionBuilderAllSenderNoNullifiers(t *testing.T) { } func TestStateDistributionWithNullifiersAllRemote(t *testing.T) { - schema1ID := tktypes.Bytes32(tktypes.RandBytes(32)) + schema1ID := tktypes.RandBytes32() state1ID := tktypes.HexBytes(tktypes.RandBytes(32)) state2ID := tktypes.HexBytes(tktypes.RandBytes(32)) contractAddr := *tktypes.RandAddress() @@ -228,7 +228,7 @@ func TestStateDistributionInvalidAssembly(t *testing.T) { func TestStateDistributionInvalidNullifiers(t *testing.T) { - schema1ID := tktypes.Bytes32(tktypes.RandBytes(32)) + schema1ID := tktypes.RandBytes32() state1ID := tktypes.HexBytes(tktypes.RandBytes(32)) contractAddr := *tktypes.RandAddress() ctx, sd := newTestStateDistributionBuilder(t, &components.PrivateTransaction{ @@ -270,7 +270,7 @@ func TestStateDistributionInvalidNullifiers(t *testing.T) { func TestStateDistributionInfoStateNoNodeName(t *testing.T) { - schema1ID := tktypes.Bytes32(tktypes.RandBytes(32)) + schema1ID := tktypes.RandBytes32() state1ID := tktypes.HexBytes(tktypes.RandBytes(32)) contractAddr := *tktypes.RandAddress() ctx, sd := newTestStateDistributionBuilder(t, &components.PrivateTransaction{ diff --git a/core/go/internal/publictxmgr/balance_manager_test.go b/core/go/internal/publictxmgr/balance_manager_test.go index 06d870120..35d5f0e79 100644 --- a/core/go/internal/publictxmgr/balance_manager_test.go +++ b/core/go/internal/publictxmgr/balance_manager_test.go @@ -334,7 +334,7 @@ func TestTopUpWithNoAmountModificationWithMultipleFuelingTxs(t *testing.T) { // current transaction completed, replace with new transaction expectedTopUpAmount2 := big.NewInt(50) m.db.ExpectQuery("SELECT.*public_txns").WillReturnRows(sqlmock.NewRows([]string{"from", `Completed__tx_hash`}). - AddRow(*bm.sourceAddress, tktypes.Bytes32(tktypes.RandBytes(32)))) + AddRow(*bm.sourceAddress, tktypes.RandBytes32())) mockAutoFuelTransactionSubmit(m, bm, false) @@ -357,7 +357,7 @@ func TestTopUpWithNoAmountModificationWithMultipleFuelingTxs(t *testing.T) { m.ethClient.On("GetBalance", mock.Anything, *bm.sourceAddress, "latest").Return(tktypes.Uint64ToUint256(50), nil).Once() m.db.ExpectQuery("SELECT.*public_txns").WillReturnRows(sqlmock.NewRows([]string{"from", `Completed__tx_hash`}). - AddRow(*bm.sourceAddress, tktypes.Bytes32(tktypes.RandBytes(32)))) + AddRow(*bm.sourceAddress, tktypes.RandBytes32())) m.ethClient.On("EstimateGasNoResolve", mock.Anything, mock.Anything, mock.Anything). Return(ethclient.EstimateGasResult{}, fmt.Errorf("pop")).Once() diff --git a/core/go/internal/publictxmgr/in_flight_transaction_state_manager_test.go b/core/go/internal/publictxmgr/in_flight_transaction_state_manager_test.go index 80847ca79..6936b16cd 100644 --- a/core/go/internal/publictxmgr/in_flight_transaction_state_manager_test.go +++ b/core/go/internal/publictxmgr/in_flight_transaction_state_manager_test.go @@ -217,7 +217,7 @@ func TestStateManagerStageOutputManagement(t *testing.T) { go func() { for i := 0; i < expectedNumberOfSignSuccessOutput; i++ { go func() { - stateManager.AddSignOutput(ctx, []byte("data"), confutil.P(tktypes.Bytes32(tktypes.RandBytes(32))), nil) + stateManager.AddSignOutput(ctx, []byte("data"), confutil.P(tktypes.RandBytes32()), nil) countChanel <- true }() } @@ -369,7 +369,7 @@ func TestStateManagerTxPersistenceManagementUpdateErrors(t *testing.T) { rsc.StageOutputsToBePersisted.TxUpdates = &BaseTXUpdates{ NewSubmission: &DBPubTxnSubmission{ from: "0x12345", - TransactionHash: tktypes.Bytes32(tktypes.RandBytes(32)), + TransactionHash: tktypes.RandBytes32(), }, } diff --git a/core/go/internal/publictxmgr/in_memory_tx_state_test.go b/core/go/internal/publictxmgr/in_memory_tx_state_test.go index a1f37186c..92c2455f9 100644 --- a/core/go/internal/publictxmgr/in_memory_tx_state_test.go +++ b/core/go/internal/publictxmgr/in_memory_tx_state_test.go @@ -47,7 +47,7 @@ const testTransactionData string = "0x7369676e6564206d657373616765" func NewTestInMemoryTxState(t *testing.T) InMemoryTxStateManager { oldTime := tktypes.TimestampNow() oldFrom := tktypes.MustEthAddress("0x4e598f6e918321dd47c86e7a077b4ab0e7414846") - oldTxHash := tktypes.Bytes32(tktypes.RandBytes(32)) + oldTxHash := tktypes.RandBytes32() oldTo := tktypes.MustEthAddress("0x6cee73cf4d5b0ac66ce2d1c0617bec4bedd09f39") oldNonce := tktypes.HexUint64(1) oldGasLimit := tktypes.HexUint64(2000) diff --git a/core/go/internal/publictxmgr/transaction_manager_test.go b/core/go/internal/publictxmgr/transaction_manager_test.go index 2d4de3dfb..0f15aed1f 100644 --- a/core/go/internal/publictxmgr/transaction_manager_test.go +++ b/core/go/internal/publictxmgr/transaction_manager_test.go @@ -123,7 +123,7 @@ func newTestPublicTxManager(t *testing.T, realDBAndSigner bool, extraSetup ...fu Keys: map[string]pldconf.StaticKeyEntryConfig{ "seed": { Encoding: "hex", - Inline: tktypes.Bytes32(tktypes.RandBytes(32)).String(), + Inline: tktypes.RandBytes32().String(), }, }, }, @@ -406,7 +406,7 @@ func fakeTxManagerInsert(t *testing.T, db *gorm.DB, txID uuid.UUID, fromStr stri // Yes, there is a slight smell of un-partitioned DB responsibilities between components // here. But the saving is critical path avoidance of one extra DB query for every block // that is mined. So it's currently considered worth this limited quirk. - fakeABI := tktypes.Bytes32(tktypes.RandBytes(32)) + fakeABI := tktypes.RandBytes32() err := db.Exec(`INSERT INTO "abis" ("hash","abi","created") VALUES (?, ?, ?)`, fakeABI, `[]`, tktypes.TimestampNow()). Error diff --git a/core/go/internal/registrymgr/registry_test.go b/core/go/internal/registrymgr/registry_test.go index 681430440..fd8212688 100644 --- a/core/go/internal/registrymgr/registry_test.go +++ b/core/go/internal/registrymgr/registry_test.go @@ -544,8 +544,8 @@ func TestHandleEventBatchOk(t *testing.T) { BlockNumber: 12345, TransactionIndex: 10, LogIndex: 20, - TransactionHash: tktypes.Bytes32(tktypes.RandBytes(32)), - Signature: tktypes.Bytes32(tktypes.RandBytes(32)), + TransactionHash: tktypes.RandBytes32(), + Signature: tktypes.RandBytes32(), }, SoliditySignature: "event1()", Address: *tktypes.RandAddress(), @@ -585,8 +585,8 @@ func TestHandleEventBatchError(t *testing.T) { BlockNumber: 12345, TransactionIndex: 10, LogIndex: 20, - TransactionHash: tktypes.Bytes32(tktypes.RandBytes(32)), - Signature: tktypes.Bytes32(tktypes.RandBytes(32)), + TransactionHash: tktypes.RandBytes32(), + Signature: tktypes.RandBytes32(), }, SoliditySignature: "event1()", Address: *tktypes.RandAddress(), diff --git a/core/go/internal/statemgr/domain_context.go b/core/go/internal/statemgr/domain_context.go index b534a87dc..27092ddcd 100644 --- a/core/go/internal/statemgr/domain_context.go +++ b/core/go/internal/statemgr/domain_context.go @@ -249,6 +249,15 @@ func (dc *domainContext) mergeInMemoryMatches(schema components.Schema, states [ } +func (dc *domainContext) GetStates(dbTX *gorm.DB, schemaID tktypes.Bytes32, ids []string) (components.Schema, []*pldapi.State, error) { + idsAny := make([]any, len(ids)) + for i, id := range ids { + idsAny[i] = id + } + query := query.NewQueryBuilder().In(".id", idsAny).Query() + return dc.ss.findStates(dc, dbTX, dc.domainName, &dc.contractAddress, schemaID, query, pldapi.StateStatusAll) +} + func (dc *domainContext) FindAvailableStates(dbTX *gorm.DB, schemaID tktypes.Bytes32, query *query.QueryJSON) (components.Schema, []*pldapi.State, error) { log.L(dc.Context).Debug("domainContext:FindAvailableStates") // Build a list of spending states diff --git a/core/go/internal/statemgr/domain_context_test.go b/core/go/internal/statemgr/domain_context_test.go index 21ff0c22c..43cc7074a 100644 --- a/core/go/internal/statemgr/domain_context_test.go +++ b/core/go/internal/statemgr/domain_context_test.go @@ -340,6 +340,12 @@ func TestStateContextMintSpendMint(t *testing.T) { // Flush the states to the database syncFlushContext(t, dc) + // Query state by ID + _, statesByID, err := dc.GetStates(ss.p.DB(), schemaID, []string{states[0].ID.String()}) + require.NoError(t, err) + assert.Len(t, statesByID, 1) + assert.Equal(t, int64(50), parseFakeCoin(t, statesByID[0]).Amount.Int64()) + // Check the DB persisted state is what we expect _, states, err = dc.FindAvailableStates(ss.p.DB(), schemaID, query.NewQueryBuilder().Sort("owner", "amount").Query()) require.NoError(t, err) diff --git a/core/go/internal/statemgr/state_test.go b/core/go/internal/statemgr/state_test.go index 4e9dcc3ef..0c6f5e165 100644 --- a/core/go/internal/statemgr/state_test.go +++ b/core/go/internal/statemgr/state_test.go @@ -201,7 +201,7 @@ func TestWriteReceivedStatesValidateHashFail(t *testing.T) { md.On("ValidateStateHashes", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("pop")) _, err := ss.WriteReceivedStates(ctx, ss.p.DB(), "domain1", []*components.StateUpsertOutsideContext{ - {ID: tktypes.RandBytes(32), SchemaID: tktypes.Bytes32(tktypes.RandBytes(32)), + {ID: tktypes.RandBytes(32), SchemaID: tktypes.RandBytes32(), Data: tktypes.RawJSON(fmt.Sprintf( `{"amount": 20, "owner": "0x615dD09124271D8008225054d85Ffe720E7a447A", "salt": "%s"}`, tktypes.RandHex(32)))}, diff --git a/core/go/internal/txmgr/block_indexing_test.go b/core/go/internal/txmgr/block_indexing_test.go index f83a1af7b..d7eec5345 100644 --- a/core/go/internal/txmgr/block_indexing_test.go +++ b/core/go/internal/txmgr/block_indexing_test.go @@ -37,7 +37,7 @@ import ( func newTestConfirm(revertReason ...[]byte) *blockindexer.IndexedTransactionNotify { txi := &blockindexer.IndexedTransactionNotify{ IndexedTransaction: pldapi.IndexedTransaction{ - Hash: tktypes.Bytes32(tktypes.RandBytes(32)), + Hash: tktypes.RandBytes32(), BlockNumber: 12345, TransactionIndex: 0, From: tktypes.MustEthAddress(tktypes.RandHex(20)), diff --git a/core/go/internal/txmgr/persisted_abi_test.go b/core/go/internal/txmgr/persisted_abi_test.go index bc3854b64..1255da0b2 100644 --- a/core/go/internal/txmgr/persisted_abi_test.go +++ b/core/go/internal/txmgr/persisted_abi_test.go @@ -34,7 +34,7 @@ func TestGetABIByHashError(t *testing.T) { }) defer done() - _, err := txm.getABIByHash(ctx, txm.p.DB(), tktypes.Bytes32(tktypes.RandBytes(32))) + _, err := txm.getABIByHash(ctx, txm.p.DB(), tktypes.RandBytes32()) assert.Regexp(t, "pop", err) } @@ -50,7 +50,7 @@ func TestGetABIByHashBadData(t *testing.T) { }) defer done() - _, err := txm.getABIByHash(ctx, txm.p.DB(), tktypes.Bytes32(tktypes.RandBytes(32))) + _, err := txm.getABIByHash(ctx, txm.p.DB(), tktypes.RandBytes32()) assert.Regexp(t, "PD012217", err) } @@ -66,7 +66,7 @@ func TestGetABIByCache(t *testing.T) { }) defer done() - hash := tktypes.Bytes32(tktypes.RandBytes(32)) + hash := tktypes.RandBytes32() // 2nd time cached (only one DB mock) for i := 0; i < 2; i++ { diff --git a/core/go/internal/txmgr/persisted_receipt_test.go b/core/go/internal/txmgr/persisted_receipt_test.go index 0d4590704..ee55057da 100644 --- a/core/go/internal/txmgr/persisted_receipt_test.go +++ b/core/go/internal/txmgr/persisted_receipt_test.go @@ -430,7 +430,7 @@ func TestDecodeEvent(t *testing.T) { _, err = txm.DecodeEvent(ctx, txm.p.DB(), []tktypes.Bytes32{validTopic0 /* missing 2nd topic*/}, []byte{}, "") assert.Regexp(t, "PD012229.*1 matched signature", err) - _, err = txm.DecodeEvent(ctx, txm.p.DB(), []tktypes.Bytes32{tktypes.Bytes32(tktypes.RandBytes(32)) /* unknown topic */}, []byte{}, "") + _, err = txm.DecodeEvent(ctx, txm.p.DB(), []tktypes.Bytes32{tktypes.RandBytes32() /* unknown topic */}, []byte{}, "") assert.Regexp(t, "PD012229", err) _, err = txm.DecodeEvent(ctx, txm.p.DB(), []tktypes.Bytes32{ /* no topics */ }, []byte{}, "") diff --git a/core/go/internal/txmgr/rpcmodule_test.go b/core/go/internal/txmgr/rpcmodule_test.go index a4cbb858a..c78ee4721 100644 --- a/core/go/internal/txmgr/rpcmodule_test.go +++ b/core/go/internal/txmgr/rpcmodule_test.go @@ -209,7 +209,7 @@ func TestPublicTransactionLifecycle(t *testing.T) { // Null on not found is the consistent ethereum pattern var abiNotFound *pldapi.StoredABI - err = rpcClient.CallRPC(ctx, &abiNotFound, "ptx_getStoredABI", tktypes.Bytes32(tktypes.RandBytes(32))) + err = rpcClient.CallRPC(ctx, &abiNotFound, "ptx_getStoredABI", tktypes.RandBytes32()) require.NoError(t, err) assert.Nil(t, abiNotFound) @@ -250,7 +250,7 @@ func TestPublicTransactionLifecycle(t *testing.T) { assert.Nil(t, txNotFound) // Finalize the deploy as a success - txHash1 := tktypes.Bytes32(tktypes.RandBytes(32)) + txHash1 := tktypes.RandBytes32() blockNumber1 := int64(12345) err = tmr.FinalizeTransactions(ctx, tmr.p.DB(), []*components.ReceiptInput{ { @@ -287,7 +287,7 @@ func TestPublicTransactionLifecycle(t *testing.T) { require.Len(t, pendingTransactions, 1) // Finalize the invoke as a revert with an encoded error - txHash2 := tktypes.Bytes32(tktypes.RandBytes(32)) + txHash2 := tktypes.RandBytes32() blockNumber2 := int64(12345) revertData, err := sampleABI.Errors()["BadValue"].EncodeCallDataValuesCtx(ctx, []any{12345}) require.NoError(t, err) @@ -445,7 +445,7 @@ func TestPublicTransactionPassthroughQueries(t *testing.T) { require.Regexp(t, "pop", err) // Query by hash - txHash := tktypes.Bytes32(tktypes.RandBytes(32)) + txHash := tktypes.RandBytes32() mockGetByHash = func(hash tktypes.Bytes32) (*pldapi.PublicTxWithBinding, error) { assert.Equal(t, txHash, hash) return tx, nil diff --git a/core/go/internal/txmgr/transaction_submission_test.go b/core/go/internal/txmgr/transaction_submission_test.go index 57ebe5475..48507b949 100644 --- a/core/go/internal/txmgr/transaction_submission_test.go +++ b/core/go/internal/txmgr/transaction_submission_test.go @@ -52,7 +52,7 @@ func TestResolveFunctionABIAndDef(t *testing.T) { _, err := txm.SendTransaction(ctx, &pldapi.TransactionInput{ TransactionBase: pldapi.TransactionBase{ Type: pldapi.TransactionTypePublic.Enum(), - ABIReference: confutil.P(tktypes.Bytes32(tktypes.RandBytes(32))), + ABIReference: confutil.P(tktypes.RandBytes32()), }, ABI: abi.ABI{}, }) diff --git a/core/go/pkg/blockindexer/block_indexer_test.go b/core/go/pkg/blockindexer/block_indexer_test.go index e3b78d2fb..d6d696863 100644 --- a/core/go/pkg/blockindexer/block_indexer_test.go +++ b/core/go/pkg/blockindexer/block_indexer_test.go @@ -901,13 +901,13 @@ func TestGetIndexedTransactionByHashErrors(t *testing.T) { p.Mock.ExpectQuery("SELECT.*indexed_transactions").WillReturnRows(sqlmock.NewRows([]string{})) - res, err := bi.GetIndexedTransactionByHash(ctx, tktypes.Bytes32(tktypes.RandBytes(32))) + res, err := bi.GetIndexedTransactionByHash(ctx, tktypes.RandBytes32()) require.NoError(t, err) assert.Nil(t, res) p.Mock.ExpectQuery("SELECT.*indexed_transactions").WillReturnError(fmt.Errorf("pop")) - _, err = bi.GetIndexedTransactionByHash(ctx, tktypes.Bytes32(tktypes.RandBytes(32))) + _, err = bi.GetIndexedTransactionByHash(ctx, tktypes.RandBytes32()) assert.Regexp(t, "pop", err) } @@ -1011,7 +1011,7 @@ func TestWaitForTransactionErrorCases(t *testing.T) { p.Mock.ExpectQuery("SELECT.*indexed_transactions").WillReturnError(fmt.Errorf("pop")) - _, err := bi.WaitForTransactionSuccess(ctx, tktypes.Bytes32(tktypes.RandBytes(32)), nil) + _, err := bi.WaitForTransactionSuccess(ctx, tktypes.RandBytes32(), nil) assert.Regexp(t, "pop", err) } @@ -1023,7 +1023,7 @@ func TestDecodeTransactionEventsFail(t *testing.T) { p.Mock.ExpectQuery("SELECT.*indexed_events").WillReturnError(fmt.Errorf("pop")) - _, err := bi.DecodeTransactionEvents(ctx, tktypes.Bytes32(tktypes.RandBytes(32)), testABI, "") + _, err := bi.DecodeTransactionEvents(ctx, tktypes.RandBytes32(), testABI, "") assert.Regexp(t, "pop", err) } @@ -1037,7 +1037,7 @@ func TestWaitForTransactionSuccessGetReceiptFail(t *testing.T) { rpcclient.WrapErrorRPC(rpcclient.RPCCodeInternalError, fmt.Errorf("pop")), ) - err := bi.getReceiptRevertError(ctx, tktypes.Bytes32(tktypes.RandBytes(32)), nil) + err := bi.getReceiptRevertError(ctx, tktypes.RandBytes32(), nil) assert.Regexp(t, "pop", err) } @@ -1053,7 +1053,7 @@ func TestWaitForTransactionSuccessGetReceiptFallback(t *testing.T) { }, ).Return(nil) - err := bi.getReceiptRevertError(ctx, tktypes.Bytes32(tktypes.RandBytes(32)), nil) + err := bi.getReceiptRevertError(ctx, tktypes.RandBytes32(), nil) assert.Regexp(t, "PD011309", err) } diff --git a/core/go/pkg/blockindexer/event_streams_test.go b/core/go/pkg/blockindexer/event_streams_test.go index 5f669f235..6094026f9 100644 --- a/core/go/pkg/blockindexer/event_streams_test.go +++ b/core/go/pkg/blockindexer/event_streams_test.go @@ -792,13 +792,13 @@ func TestProcessCatchupEventMultiPageRealDB(t *testing.T) { allTransactions := []*pldapi.IndexedTransaction{} allEvents := []*pldapi.IndexedEvent{} for b := 1; b < 14; b++ { - blockHash := tktypes.Bytes32(tktypes.RandBytes(32)) + blockHash := tktypes.RandBytes32() allBlocks = append(allBlocks, &pldapi.IndexedBlock{ Number: int64(b), Hash: blockHash, }) for tx := 0; tx < 8; tx++ { - txHash := tktypes.Bytes32(tktypes.RandBytes(32)) + txHash := tktypes.RandBytes32() allTransactions = append(allTransactions, &pldapi.IndexedTransaction{ Hash: txHash, BlockNumber: int64(b), diff --git a/core/go/pkg/ethclient/function_client_test.go b/core/go/pkg/ethclient/function_client_test.go index 57d73f934..8d5b44a7c 100644 --- a/core/go/pkg/ethclient/function_client_test.go +++ b/core/go/pkg/ethclient/function_client_test.go @@ -676,7 +676,7 @@ func TestInvokeNewWidgetCustomError(t *testing.T) { func TestNewWSOk(t *testing.T) { - expected := tktypes.Bytes32(tktypes.RandBytes(32)) + expected := tktypes.RandBytes32() ctx, ecf, done := newTestClientAndServer(t, &mockEth{ eth_sendRawTransaction: func(ctx context.Context, rawTX tktypes.HexBytes) (tktypes.HexBytes, error) { return tktypes.HexBytes(expected[:]), nil diff --git a/doc-site/docs/architecture/noto.md b/doc-site/docs/architecture/noto.md index 146a1d8a2..2e8b9d9e1 100644 --- a/doc-site/docs/architecture/noto.md +++ b/doc-site/docs/architecture/noto.md @@ -24,16 +24,23 @@ Creates a new Noto token, with a new address on the base ledger. "type": "constructor", "inputs": [ {"name": "notary", "type": "string"}, + {"name": "notaryMode", "type": "string"}, {"name": "implementation", "type": "string"}, - {"name": "restrictMinting", "type": "boolean"}, - {"name": "allowBurning", "type": "boolean"}, - {"name": "hooks", "type": "tuple", "components": [ - {"name": "privateGroup", "type": "tuple", "components": [ - {"name": "salt", "type": "bytes32"}, - {"name": "members", "type": "string[]"} + {"name": "options", "type": "tuple", "components": [ + {"name": "basic", "type": "tuple", "components": [ + {"name": "restrictMint", "type": "boolean"}, + {"name": "allowBurn", "type": "boolean"}, + {"name": "allowLock", "type": "boolean"}, + {"name": "restrictUnlock", "type": "boolean"}, ]}, - {"name": "publicAddress", "type": "address"}, - {"name": "privateAddress", "type": "address"} + {"name": "hooks", "type": "tuple", "components": [ + {"name": "privateGroup", "type": "tuple", "components": [ + {"name": "salt", "type": "bytes32"}, + {"name": "members", "type": "string[]"} + ]}, + {"name": "publicAddress", "type": "address"}, + {"name": "privateAddress", "type": "address"} + ]} ]} ] } @@ -42,10 +49,13 @@ Creates a new Noto token, with a new address on the base ledger. Inputs: * **notary** - lookup string for the identity that will serve as the notary for this token instance. May be located at this node or another node +* **notaryMode** - choose the notary's mode of operation - must be "basic" or "hooks" * **implementation** - (optional) the name of a non-default Noto implementation that has previously been registered -* **restrictMinting** - (optional - default true) only allow the notary to request mint -* **allowBurning** - (optional - default true) allow token owners to request burn -* **hooks** - (optional) specify a [Pente](../pente) private smart contract that will be called for each Noto transaction, to provide custom logic and policies +* **options.basic.restrictMint** - (optional - default true) only allow the notary to request mint +* **options.basic.allowBurn** - (optional - default true) allow token owners to request burn +* **options.basic.allowLock** - (optional - default true) allow token owners to lock tokens (for purposes such as preparing or delegating transfers) +* **options.basic.restrictUnlock** - (optional - default true) only allow the lock creator to unlock tokens +* **options.hooks** - (optional) specify a [Pente](../pente) private smart contract that will be called for each Noto transaction, to provide custom logic and policies ### mint diff --git a/doc-site/docs/tutorials/bond-issuance.md b/doc-site/docs/tutorials/bond-issuance.md index 004e88c3e..b48f76685 100644 --- a/doc-site/docs/tutorials/bond-issuance.md +++ b/doc-site/docs/tutorials/bond-issuance.md @@ -24,7 +24,7 @@ Below is a walkthrough of each step in the example, with an explanation of what const notoFactory = new NotoFactory(paladin1, "noto"); const notoCash = await notoFactory.newNoto(cashIssuer, { notary: cashIssuer, - restrictMinting: true, + notaryMode: "basic", }); ``` @@ -127,19 +127,21 @@ visible to the bond issuer and custodian, but will be atomically linked to the N ```typescript const notoBond = await notoFactory.newNoto(bondIssuer, { notary: bondCustodian, - hooks: { - privateGroup: issuerCustodianGroup.group, - publicAddress: issuerCustodianGroup.address, - privateAddress: bondTracker.address, + notaryMode: "hooks", + options: { + hooks: { + privateGroup: issuerCustodianGroup.group, + publicAddress: issuerCustodianGroup.address, + privateAddress: bondTracker.address, + }, }, - restrictMinting: false, }); ``` Now that the public and private tracking contracts have been deployed, the actual Noto token for the bond can be created. The "hooks" configuration points it to the private hooks contract that was deployed in the previous step. -For this token, "restrictMinting" is disabled, because the hooks can enforce more flexible rules on both mint and transfer. +For this token, "restrictMint" is disabled, because the hooks can enforce more flexible rules on both mint and transfer. #### Create factory for atomic transactions diff --git a/domains/integration-test/helpers/helpers.go b/domains/integration-test/helpers/helpers.go index ffeb22aca..6fbe59872 100644 --- a/domains/integration-test/helpers/helpers.go +++ b/domains/integration-test/helpers/helpers.go @@ -140,13 +140,16 @@ func (dth *DomainTransactionHelper) Prepare(signer string) *testbed.TransactionR return &result } -func (st *SentDomainTransaction) Wait() { +func (st *SentDomainTransaction) Wait() map[string]any { result := <-st.result switch r := result.(type) { case error: require.NoError(st.t, r) + case map[string]any: + return r default: } + return nil } func toJSON(t *testing.T, v any) []byte { diff --git a/domains/integration-test/helpers/noto_helper.go b/domains/integration-test/helpers/noto_helper.go index 8ec54613c..22d257365 100644 --- a/domains/integration-test/helpers/noto_helper.go +++ b/domains/integration-test/helpers/noto_helper.go @@ -42,10 +42,21 @@ type NotoHelper struct { } func DeployNoto(ctx context.Context, t *testing.T, rpc rpcbackend.Backend, domainName, notary string, hooks *tktypes.EthAddress) *NotoHelper { + notaryMode := types.NotaryModeBasic + if hooks != nil { + notaryMode = types.NotaryModeHooks + } + var addr tktypes.EthAddress rpcerr := rpc.CallRPC(ctx, &addr, "testbed_deploy", domainName, "notary", &types.ConstructorParams{ - Notary: notary + "@node1", - Hooks: &types.HookParams{PublicAddress: hooks}, + Notary: notary + "@node1", + NotaryMode: notaryMode, + Options: types.NotoOptions{ + Hooks: &types.NotoHooksOptions{ + PublicAddress: hooks, + DevUsePublicHooks: true, + }, + }, }) if rpcerr != nil { assert.NoError(t, rpcerr.Error()) @@ -78,3 +89,23 @@ func (n *NotoHelper) ApproveTransfer(ctx context.Context, params *types.ApproveP fn := types.NotoABI.Functions()["approveTransfer"] return NewDomainTransactionHelper(ctx, n.t, n.rpc, n.Address, fn, toJSON(n.t, params)) } + +func (n *NotoHelper) Lock(ctx context.Context, params *types.LockParams) *DomainTransactionHelper { + fn := types.NotoABI.Functions()["lock"] + return NewDomainTransactionHelper(ctx, n.t, n.rpc, n.Address, fn, toJSON(n.t, params)) +} + +func (n *NotoHelper) Unlock(ctx context.Context, params *types.UnlockParams) *DomainTransactionHelper { + fn := types.NotoABI.Functions()["unlock"] + return NewDomainTransactionHelper(ctx, n.t, n.rpc, n.Address, fn, toJSON(n.t, params)) +} + +func (n *NotoHelper) PrepareUnlock(ctx context.Context, params *types.UnlockParams) *DomainTransactionHelper { + fn := types.NotoABI.Functions()["prepareUnlock"] + return NewDomainTransactionHelper(ctx, n.t, n.rpc, n.Address, fn, toJSON(n.t, params)) +} + +func (n *NotoHelper) DelegateLock(ctx context.Context, params *types.DelegateLockParams) *DomainTransactionHelper { + fn := types.NotoABI.Functions()["delegateLock"] + return NewDomainTransactionHelper(ctx, n.t, n.rpc, n.Address, fn, toJSON(n.t, params)) +} diff --git a/domains/integration-test/pvp_test.go b/domains/integration-test/pvp_test.go index 5b3ec9235..21dc4cabe 100644 --- a/domains/integration-test/pvp_test.go +++ b/domains/integration-test/pvp_test.go @@ -22,14 +22,17 @@ import ( "time" "github.com/hyperledger/firefly-common/pkg/log" + "github.com/hyperledger/firefly-signer/pkg/abi" "github.com/hyperledger/firefly-signer/pkg/rpcbackend" "github.com/kaleido-io/paladin/core/pkg/testbed" "github.com/kaleido-io/paladin/domains/integration-test/helpers" + "github.com/kaleido-io/paladin/domains/noto/pkg/noto" nototypes "github.com/kaleido-io/paladin/domains/noto/pkg/types" zetotests "github.com/kaleido-io/paladin/domains/zeto/integration-test" zetotypes "github.com/kaleido-io/paladin/domains/zeto/pkg/types" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner/zetosignerapi" "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" + "github.com/kaleido-io/paladin/toolkit/pkg/pldapi" "github.com/kaleido-io/paladin/toolkit/pkg/query" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" @@ -62,6 +65,66 @@ type PentePreparedTransaction struct { EncodedCall tktypes.HexBytes `json:"encodedCall"` } +func decodeTransactionResult(t *testing.T, resultInput map[string]any) *testbed.TransactionResult { + resultJSON, err := json.Marshal(resultInput) + require.NoError(t, err) + var result testbed.TransactionResult + err = json.Unmarshal(resultJSON, &result) + require.NoError(t, err) + return &result +} + +func extractLockID(noto noto.Noto, invokeResult *testbed.TransactionResult) (tktypes.Bytes32, error) { + for _, state := range invokeResult.InfoStates { + if state.Schema.String() == noto.LockInfoSchemaID() { + var lockInfo map[string]any + err := json.Unmarshal(state.Data, &lockInfo) + if err != nil { + return tktypes.Bytes32{}, err + } + return tktypes.MustParseBytes32(lockInfo["lockId"].(string)), nil + } + } + return tktypes.Bytes32{}, nil +} + +// TODO: make this easier to extract +func buildUnlock(ctx context.Context, notoDomain noto.Noto, abi abi.ABI, lockID tktypes.Bytes32, prepareUnlockResult *testbed.TransactionResult) ([]*pldapi.StateEncoded, []*pldapi.StateEncoded, []byte, error) { + notoInputStates := make([]*pldapi.StateEncoded, 0, len(prepareUnlockResult.ReadStates)) + notoOutputStates := make([]*pldapi.StateEncoded, 0, len(prepareUnlockResult.InfoStates)) + lockedInputs := make([]tktypes.HexBytes, 0) + lockedOutputs := make([]tktypes.HexBytes, 0) + unlockedOutputs := make([]tktypes.HexBytes, 0) + for _, input := range prepareUnlockResult.ReadStates { + notoInputStates = append(notoInputStates, input) + lockedInputs = append(lockedInputs, input.ID) + } + for _, output := range prepareUnlockResult.InfoStates { + switch output.Schema.String() { + case notoDomain.CoinSchemaID(): + notoOutputStates = append(notoOutputStates, output) + unlockedOutputs = append(unlockedOutputs, output.ID) + case notoDomain.LockedCoinSchemaID(): + notoOutputStates = append(notoOutputStates, output) + lockedOutputs = append(lockedOutputs, output.ID) + } + } + + unlockParams, err := json.Marshal(map[string]any{ + "lockId": lockID, + "lockedInputs": lockedInputs, + "lockedOutputs": lockedOutputs, + "outputs": unlockedOutputs, + "signature": "0x", + "data": "0x", + }) + if err != nil { + return nil, nil, nil, err + } + encodedCall, err := abi.Functions()["unlock"].EncodeCallDataJSONCtx(ctx, unlockParams) + return notoInputStates, notoOutputStates, encodedCall, err +} + func TestNotoForNoto(t *testing.T) { pvpNotoNoto(t, testbed.HDWalletSeedScopedToTest(), false) } @@ -342,8 +405,32 @@ func TestNotoForZeto(t *testing.T) { TokenValue2: tktypes.Int64ToInt256(1), }) - log.L(ctx).Infof("Prepare the transfers") - transferNoto := noto.Transfer(ctx, bob, 1).Prepare(alice) + log.L(ctx).Infof("Prepare the Noto transfer") + notoLock := noto.Lock(ctx, ¬otypes.LockParams{ + Amount: tktypes.Int64ToInt256(1), + }).SignAndSend(alice).Wait() + notoLockResult := decodeTransactionResult(t, notoLock) + + lockID, err := extractLockID(notoDomain, notoLockResult) + require.NoError(t, err) + require.NotEmpty(t, lockID) + + time.Sleep(1 * time.Second) // TODO: remove + notoPrepareUnlock := noto.PrepareUnlock(ctx, ¬otypes.UnlockParams{ + LockID: lockID, + From: alice, + Recipients: []*nototypes.UnlockRecipient{{ + To: bob, + Amount: tktypes.Int64ToInt256(1), + }}, + }).SignAndSend(alice).Wait() + require.NotNil(t, notoPrepareUnlock) + prepareUnlockResult := decodeTransactionResult(t, notoPrepareUnlock) + + notoInputStates, notoOutputStates, transferNoto, err := buildUnlock(ctx, notoDomain, noto.ABI, lockID, prepareUnlockResult) + require.NoError(t, err) + + log.L(ctx).Infof("Prepare the Zeto transfer") transferZeto := zeto.Transfer(ctx, alice, 1).Prepare(bob) zeto.Lock(ctx, tktypes.MustEthAddress(bobKey.Verifier.Verifier), transferZeto.EncodedCall).SignAndSend(bob).Wait() @@ -354,8 +441,8 @@ func TestNotoForZeto(t *testing.T) { // TODO: should probably include the full encoded calls (including the zkp) log.L(ctx).Infof("Record the prepared transfers") sent := swap.Prepare(ctx, &helpers.StateData{ - Inputs: transferNoto.InputStates, - Outputs: transferNoto.OutputStates, + Inputs: notoInputStates, + Outputs: notoOutputStates, }).SignAndSend(alice).Wait(5 * time.Second) require.NoError(t, sent.Error()) sent = swap.Prepare(ctx, &helpers.StateData{ @@ -374,15 +461,11 @@ func TestNotoForZeto(t *testing.T) { log.L(ctx).Infof("Bob proposes tokens: contract=%s value=%s inputs=%+v outputs=%+v", bobData["tokenAddress"], bobData["tokenValue"], bobStates["inputs"], bobStates["outputs"]) - var transferNotoExtra nototypes.NotoTransferMetadata - err = json.Unmarshal(transferNoto.PreparedMetadata, &transferNotoExtra) - require.NoError(t, err) - log.L(ctx).Infof("Create Atom instance") transferAtom := atomFactory.Create(ctx, alice, []*helpers.AtomOperation{ { ContractAddress: noto.Address, - CallData: transferNotoExtra.TransferWithApproval.EncodedCall, + CallData: transferNoto, }, { ContractAddress: zeto.Address, @@ -402,15 +485,9 @@ func TestNotoForZeto(t *testing.T) { // TODO: all parties should verify the Atom against the original proposed trade // If any party found a discrepancy at this point, they could cancel the swap (last chance to back out) - var transferNotoParams NotoTransferParams - err = json.Unmarshal(transferNoto.PreparedTransaction.Data, &transferNotoParams) - require.NoError(t, err) - log.L(ctx).Infof("Approve both transfers") - noto.ApproveTransfer(ctx, ¬otypes.ApproveParams{ - Inputs: transferNoto.InputStates, - Outputs: transferNoto.OutputStates, - Data: transferNotoParams.Data, + noto.DelegateLock(ctx, ¬otypes.DelegateLockParams{ + LockID: lockID, Delegate: transferAtom.Address, }).SignAndSend(alice).Wait() zeto.Lock(ctx, transferAtom.Address, transferZeto.EncodedCall).SignAndSend(bob).Wait() @@ -420,15 +497,15 @@ func TestNotoForZeto(t *testing.T) { require.NoError(t, sent.Error()) // TODO: better way to wait for events to be indexed after Atom execution - time.Sleep(3 * time.Second) + time.Sleep(1 * time.Second) notoCoins = findAvailableCoins[nototypes.NotoCoinState](t, ctx, rpc, notoDomain.Name(), notoDomain.CoinSchemaID(), noto.Address, nil) require.NoError(t, err) require.Len(t, notoCoins, 2) - assert.Equal(t, int64(1), notoCoins[0].Data.Amount.Int().Int64()) - assert.Equal(t, bobKey.Verifier.Verifier, notoCoins[0].Data.Owner.String()) - assert.Equal(t, int64(9), notoCoins[1].Data.Amount.Int().Int64()) - assert.Equal(t, aliceKey.Verifier.Verifier, notoCoins[1].Data.Owner.String()) + assert.Equal(t, int64(9), notoCoins[0].Data.Amount.Int().Int64()) + assert.Equal(t, aliceKey.Verifier.Verifier, notoCoins[0].Data.Owner.String()) + assert.Equal(t, int64(1), notoCoins[1].Data.Amount.Int().Int64()) + assert.Equal(t, bobKey.Verifier.Verifier, notoCoins[1].Data.Owner.String()) zetoCoins = findAvailableCoins[zetotypes.ZetoCoinState](t, ctx, rpc, zetoDomain.Name(), zetoDomain.CoinSchemaID(), zeto.Address, nil) require.NoError(t, err) diff --git a/domains/integration-test/util.go b/domains/integration-test/util.go index 22fb5cd6b..5d0d895b3 100644 --- a/domains/integration-test/util.go +++ b/domains/integration-test/util.go @@ -46,7 +46,8 @@ func newTestbed(t *testing.T, hdWalletSeed *testbed.UTInitFunction, domains map[ tb := testbed.NewTestBed() url, conf, done, err := tb.StartForTest("./testbed.config.yaml", domains, hdWalletSeed) assert.NoError(t, err) - rpc := rpcbackend.NewRPCClient(resty.New().SetBaseURL(url)) + rc := resty.New().SetBaseURL(url) + rpc := rpcbackend.NewRPCClient(rc) return done, conf, tb, rpc } diff --git a/domains/noto/build.gradle b/domains/noto/build.gradle index 32e6ebe11..9c192c081 100644 --- a/domains/noto/build.gradle +++ b/domains/noto/build.gradle @@ -60,7 +60,6 @@ task copyInternalSolidity(type: Copy) { include 'contracts/domains/interfaces/INoto.sol/INoto.json' include 'contracts/domains/noto/NotoFactory.sol/NotoFactory.json' include 'contracts/domains/noto/Noto.sol/Noto.json' - include 'contracts/domains/noto/NotoSelfSubmit.sol/NotoSelfSubmit.json' include 'contracts/private/interfaces/INotoHooks.sol/INotoHooks.json' } diff --git a/domains/noto/internal/msgs/en_errors.go b/domains/noto/internal/msgs/en_errors.go index 4884571db..6189d113e 100644 --- a/domains/noto/internal/msgs/en_errors.go +++ b/domains/noto/internal/msgs/en_errors.go @@ -42,7 +42,7 @@ var ( MsgUnexpectedConfigType = ffe("PD200000", "Unexpected config type: %s") MsgUnknownFunction = ffe("PD200001", "Unknown function: %s") MsgUnexpectedFunctionSignature = ffe("PD200002", "Unexpected signature for function '%s': expected=%s actual=%s") - MsgUnknownSchema = ffe("PD200003", "Unknown schema: %s") + MsgUnexpectedSchema = ffe("PD200003", "Unexpected schema: %s") MsgInvalidListInput = ffe("PD200004", "Invalid item in list %s[%d] (%s): %s") MsgInsufficientFunds = ffe("PD200005", "Insufficient funds (available=%s)") MsgInvalidStateData = ffe("PD200006", "State data %s is invalid: %s") @@ -63,5 +63,11 @@ var ( MsgNotImplemented = ffe("PD200022", "Not implemented") MsgInvalidDelegate = ffe("PD200023", "Invalid delegate: %s") MsgNoDomainReceipt = ffe("PD200024", "Not implemented. See state receipt for coin transfers") - MsgNoBurning = ffe("PD200025", "Burn is not enabled") + MsgBurnNotAllowed = ffe("PD200025", "Burn is not enabled") + MsgNoStatesSpecified = ffe("PD200026", "No states were specified") + MsgUnlockNotAllowed = ffe("PD200027", "Cannot unlock states owned by '%s'") + MsgLockIDNotFound = ffe("PD200028", "Lock ID not found") + MsgMissingStateData = ffe("PD200029", "Missing state data for one or more states: %s") + MsgLockNotAllowed = ffe("PD200030", "Lock is not enabled") + MsgUnlockOnlyCreator = ffe("PD200031", "Only the lock creator can perform unlock: expected=%s actual=%s") ) diff --git a/domains/noto/internal/noto/e2e_noto_test.go b/domains/noto/internal/noto/e2e_noto_test.go index a2ba9113b..565bc5cfd 100644 --- a/domains/noto/internal/noto/e2e_noto_test.go +++ b/domains/noto/internal/noto/e2e_noto_test.go @@ -19,6 +19,7 @@ import ( "context" "encoding/json" "testing" + "time" "github.com/go-resty/resty/v2" @@ -30,8 +31,10 @@ import ( "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" "github.com/kaleido-io/paladin/toolkit/pkg/log" "github.com/kaleido-io/paladin/toolkit/pkg/pldapi" + "github.com/kaleido-io/paladin/toolkit/pkg/pldclient" "github.com/kaleido-io/paladin/toolkit/pkg/plugintk" "github.com/kaleido-io/paladin/toolkit/pkg/query" + "github.com/kaleido-io/paladin/toolkit/pkg/rpcclient" "github.com/kaleido-io/paladin/toolkit/pkg/solutils" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" @@ -39,9 +42,6 @@ import ( "github.com/stretchr/testify/require" ) -//go:embed abis/NotoSelfSubmit.json -var notoSelfSubmitJSON []byte - var ( notaryName = "notary@node1" recipient1Name = "recipient1@node1" @@ -95,12 +95,14 @@ func newNotoDomain(t *testing.T, config *types.DomainConfig) (*Noto, *testbed.Te } } -func newTestbed(t *testing.T, hdWalletSeed *testbed.UTInitFunction, domains map[string]*testbed.TestbedDomain) (context.CancelFunc, testbed.Testbed, rpcbackend.Backend) { +func newTestbed(t *testing.T, hdWalletSeed *testbed.UTInitFunction, domains map[string]*testbed.TestbedDomain) (context.CancelFunc, testbed.Testbed, rpcbackend.Backend, pldclient.PaladinClient) { tb := testbed.NewTestBed() url, _, done, err := tb.StartForTest("../../testbed.config.yaml", domains, hdWalletSeed) assert.NoError(t, err) - rpc := rpcbackend.NewRPCClient(resty.New().SetBaseURL(url)) - return done, tb, rpc + rc := resty.New().SetBaseURL(url) + rpc := rpcbackend.NewRPCClient(rc) + client := pldclient.Wrap(rpcclient.WrapRestyClient(rc)) + return done, tb, rpc, client } func findAvailableCoins(t *testing.T, ctx context.Context, rpc rpcbackend.Backend, noto *Noto, address tktypes.EthAddress, jq *query.QueryJSON) []*types.NotoCoinState { @@ -120,6 +122,63 @@ func findAvailableCoins(t *testing.T, ctx context.Context, rpc rpcbackend.Backen return notoCoins } +func findLockedCoins(t *testing.T, ctx context.Context, rpc rpcbackend.Backend, noto *Noto, address tktypes.EthAddress, jq *query.QueryJSON) []*types.NotoCoinState { + if jq == nil { + jq = query.NewQueryBuilder().Limit(100).Query() + } + var notoCoins []*types.NotoCoinState + rpcerr := rpc.CallRPC(ctx, ¬oCoins, "pstate_queryContractStates", + noto.name, + address, + noto.lockedCoinSchema.Id, + jq, + "available") + if rpcerr != nil { + require.NoError(t, rpcerr.Error()) + } + return notoCoins +} + +func extractLockID(noto *Noto, invokeResult *testbed.TransactionResult) (tktypes.Bytes32, error) { + for _, state := range invokeResult.InfoStates { + if state.Schema.String() == noto.lockInfoSchema.Id { + lockInfo, err := noto.unmarshalLock(string(state.Data)) + if err != nil { + return tktypes.Bytes32{}, err + } + return lockInfo.LockID, nil + } + } + return tktypes.Bytes32{}, nil +} + +// TODO: make this easier to extract +func buildUnlock(notoDomain *Noto, lockID tktypes.Bytes32, prepareUnlockResult *testbed.TransactionResult) *NotoUnlockParams { + lockedInputs := make([]string, 0) + lockedOutputs := make([]string, 0) + unlockedOutputs := make([]string, 0) + for _, input := range prepareUnlockResult.ReadStates { + lockedInputs = append(lockedInputs, input.ID.String()) + } + for _, output := range prepareUnlockResult.InfoStates { + switch output.Schema.String() { + case notoDomain.CoinSchemaID(): + unlockedOutputs = append(unlockedOutputs, output.ID.String()) + case notoDomain.LockedCoinSchemaID(): + lockedOutputs = append(lockedOutputs, output.ID.String()) + } + } + + return &NotoUnlockParams{ + LockID: lockID, + LockedInputs: lockedInputs, + LockedOutputs: lockedOutputs, + Outputs: unlockedOutputs, + Signature: tktypes.HexBytes{}, + Data: tktypes.HexBytes{}, + } +} + func TestNoto(t *testing.T) { ctx := context.Background() log.L(ctx).Infof("TestNoto") @@ -140,7 +199,7 @@ func TestNoto(t *testing.T) { noto, notoTestbed := newNotoDomain(t, &types.DomainConfig{ FactoryAddress: contracts["factory"], }) - done, tb, rpc := newTestbed(t, hdWalletSeed, map[string]*testbed.TestbedDomain{ + done, tb, rpc, _ := newTestbed(t, hdWalletSeed, map[string]*testbed.TestbedDomain{ domainName: notoTestbed, }) defer done() @@ -156,7 +215,8 @@ func TestNoto(t *testing.T) { var notoAddress tktypes.EthAddress rpcerr := rpc.CallRPC(ctx, ¬oAddress, "testbed_deploy", domainName, "me", &types.ConstructorParams{ - Notary: notaryName, + Notary: notaryName, + NotaryMode: types.NotaryModeBasic, }) if rpcerr != nil { require.NoError(t, rpcerr.Error()) @@ -219,7 +279,7 @@ func TestNoto(t *testing.T) { ABI: types.NotoABI, }, true) require.NotNil(t, rpcerr) - assert.ErrorContains(t, rpcerr.Error(), "PD200005") + assert.ErrorContains(t, rpcerr.Error(), "assemble result was REVERT") coins = findAvailableCoins(t, ctx, rpc, noto, notoAddress, nil) require.Len(t, coins, 1) @@ -322,7 +382,7 @@ func TestNotoApprove(t *testing.T) { noto, notoTestbed := newNotoDomain(t, &types.DomainConfig{ FactoryAddress: contracts["factory"], }) - done, tb, rpc := newTestbed(t, hdWalletSeed, map[string]*testbed.TestbedDomain{ + done, tb, rpc, _ := newTestbed(t, hdWalletSeed, map[string]*testbed.TestbedDomain{ domainName: notoTestbed, }) defer done() @@ -334,7 +394,8 @@ func TestNotoApprove(t *testing.T) { var notoAddress tktypes.EthAddress rpcerr := rpc.CallRPC(ctx, ¬oAddress, "testbed_deploy", domainName, "me", &types.ConstructorParams{ - Notary: notaryName, + Notary: notaryName, + NotaryMode: types.NotaryModeBasic, }) if rpcerr != nil { require.NoError(t, rpcerr.Error()) @@ -414,9 +475,9 @@ func TestNotoApprove(t *testing.T) { log.L(ctx).Infof("Claimed with transaction: %s", receipt.TransactionHash) } -func TestNotoSelfSubmit(t *testing.T) { +func TestNotoLock(t *testing.T) { ctx := context.Background() - log.L(ctx).Infof("TestNotoSelfSubmit") + log.L(ctx).Infof("TestNotoLock") domainName := "noto_" + tktypes.RandHex(8) log.L(ctx).Infof("Domain name = %s", domainName) @@ -425,84 +486,38 @@ func TestNotoSelfSubmit(t *testing.T) { log.L(ctx).Infof("Deploying Noto factory") contractSource := map[string][]byte{ "factory": notoFactoryJSON, - "noto": notoSelfSubmitJSON, } contracts := deployContracts(ctx, t, hdWalletSeed, contractSource) for name, address := range contracts { log.L(ctx).Infof("%s deployed to %s", name, address) } - factoryAddress, err := tktypes.ParseEthAddress(contracts["factory"]) - require.NoError(t, err) - noto, notoTestbed := newNotoDomain(t, &types.DomainConfig{ - FactoryAddress: factoryAddress.String(), + FactoryAddress: contracts["factory"], }) - done, tb, rpc := newTestbed(t, hdWalletSeed, map[string]*testbed.TestbedDomain{ + done, tb, rpc, client := newTestbed(t, hdWalletSeed, map[string]*testbed.TestbedDomain{ domainName: notoTestbed, }) defer done() - notaryKey, err := tb.ResolveKey(ctx, notaryName, algorithms.ECDSA_SECP256K1, verifiers.ETH_ADDRESS) - require.NoError(t, err) recipient1Key, err := tb.ResolveKey(ctx, recipient1Name, algorithms.ECDSA_SECP256K1, verifiers.ETH_ADDRESS) require.NoError(t, err) recipient2Key, err := tb.ResolveKey(ctx, recipient2Name, algorithms.ECDSA_SECP256K1, verifiers.ETH_ADDRESS) require.NoError(t, err) - notoFactory := solutils.MustLoadBuild(notoFactoryJSON) - _, err = tb.ExecTransactionSync(ctx, &pldapi.TransactionInput{ - TransactionBase: pldapi.TransactionBase{ - Type: pldapi.TransactionTypePublic.Enum(), - Function: "registerImplementation", - From: notaryName, - To: factoryAddress, - Data: tktypes.JSONString(map[string]any{ - "name": "selfsubmit", - "implementation": contracts["noto"], - }), - }, - ABI: notoFactory.ABI, - }) - require.NoError(t, err) - - var callResult map[string]any - rpcerr := rpc.CallRPC(ctx, &callResult, "ptx_call", &pldapi.TransactionCall{ - TransactionInput: pldapi.TransactionInput{ - TransactionBase: pldapi.TransactionBase{ - Type: pldapi.TransactionTypePublic.Enum(), - To: factoryAddress, - Function: "getImplementation", - From: notaryName, - Data: tktypes.JSONString(map[string]any{ - "name": "selfsubmit", - }), - }, - ABI: notoFactory.ABI, - }, - PublicCallOptions: pldapi.PublicCallOptions{ - Block: "latest", - }, - }) - if rpcerr != nil { - require.NoError(t, rpcerr.Error()) - } - require.NotEmpty(t, callResult["implementation"]) - log.L(ctx).Infof("Deploying an instance of Noto") var notoAddress tktypes.EthAddress - rpcerr = rpc.CallRPC(ctx, ¬oAddress, "testbed_deploy", + rpcerr := rpc.CallRPC(ctx, ¬oAddress, "testbed_deploy", domainName, "me", &types.ConstructorParams{ - Notary: notaryName, - Implementation: "selfsubmit", - }, - ) + Notary: notaryName, + NotaryMode: types.NotaryModeBasic, + }) if rpcerr != nil { require.NoError(t, rpcerr.Error()) } log.L(ctx).Infof("Noto instance deployed to %s", notoAddress) - log.L(ctx).Infof("Mint 100 from notary to notary") + log.L(ctx).Infof("Mint 100 from notary to recipient1") var invokeResult testbed.TransactionResult rpcerr = rpc.CallRPC(ctx, &invokeResult, "testbed_invoke", &pldapi.TransactionInput{ TransactionBase: pldapi.TransactionBase{ @@ -510,7 +525,7 @@ func TestNotoSelfSubmit(t *testing.T) { To: ¬oAddress, Function: "mint", Data: toJSON(t, &types.MintParams{ - To: notaryName, + To: recipient1Name, Amount: tktypes.Int64ToInt256(100), }), }, @@ -521,19 +536,17 @@ func TestNotoSelfSubmit(t *testing.T) { } coins := findAvailableCoins(t, ctx, rpc, noto, notoAddress, nil) - require.NoError(t, err) - assert.Len(t, coins, 1) + require.Len(t, coins, 1) assert.Equal(t, int64(100), coins[0].Data.Amount.Int().Int64()) - assert.Equal(t, notaryKey.Verifier.Verifier, coins[0].Data.Owner.String()) + assert.Equal(t, recipient1Key.Verifier.Verifier, coins[0].Data.Owner.String()) - log.L(ctx).Infof("Transfer 50 from notary to recipient1") + log.L(ctx).Infof("Lock 50 from recipient1") rpcerr = rpc.CallRPC(ctx, &invokeResult, "testbed_invoke", &pldapi.TransactionInput{ TransactionBase: pldapi.TransactionBase{ - From: notaryName, + From: recipient1Name, To: ¬oAddress, - Function: "transfer", - Data: toJSON(t, &types.TransferParams{ - To: recipient1Name, + Function: "lock", + Data: toJSON(t, &types.LockParams{ Amount: tktypes.Int64ToInt256(50), }), }, @@ -543,16 +556,20 @@ func TestNotoSelfSubmit(t *testing.T) { require.NoError(t, rpcerr.Error()) } - coins = findAvailableCoins(t, ctx, rpc, noto, notoAddress, nil) + lockID, err := extractLockID(noto, &invokeResult) require.NoError(t, err) - require.Len(t, coins, 2) + require.NotEmpty(t, lockID) + coins = findLockedCoins(t, ctx, rpc, noto, notoAddress, nil) + require.Len(t, coins, 1) + assert.Equal(t, int64(50), coins[0].Data.Amount.Int().Int64()) + assert.Equal(t, recipient1Key.Verifier.Verifier, coins[0].Data.Owner.String()) + coins = findAvailableCoins(t, ctx, rpc, noto, notoAddress, nil) + require.Len(t, coins, 1) assert.Equal(t, int64(50), coins[0].Data.Amount.Int().Int64()) assert.Equal(t, recipient1Key.Verifier.Verifier, coins[0].Data.Owner.String()) - assert.Equal(t, int64(50), coins[1].Data.Amount.Int().Int64()) - assert.Equal(t, notaryKey.Verifier.Verifier, coins[1].Data.Owner.String()) - log.L(ctx).Infof("Transfer 50 from recipient1 to recipient2") + log.L(ctx).Infof("Transfer 50 from recipient1 to recipient2 (succeeds but does not use locked state)") rpcerr = rpc.CallRPC(ctx, &invokeResult, "testbed_invoke", &pldapi.TransactionInput{ TransactionBase: pldapi.TransactionBase{ From: recipient1Name, @@ -569,12 +586,72 @@ func TestNotoSelfSubmit(t *testing.T) { require.NoError(t, rpcerr.Error()) } + coins = findLockedCoins(t, ctx, rpc, noto, notoAddress, nil) + require.Len(t, coins, 1) + assert.Equal(t, int64(50), coins[0].Data.Amount.Int().Int64()) + assert.Equal(t, recipient1Key.Verifier.Verifier, coins[0].Data.Owner.String()) coins = findAvailableCoins(t, ctx, rpc, noto, notoAddress, nil) - require.NoError(t, err) - require.Len(t, coins, 2) + require.Len(t, coins, 1) + assert.Equal(t, int64(50), coins[0].Data.Amount.Int().Int64()) + assert.Equal(t, recipient2Key.Verifier.Verifier, coins[0].Data.Owner.String()) + log.L(ctx).Infof("Prepare unlock that will send all 50 to recipient2") + rpcerr = rpc.CallRPC(ctx, &invokeResult, "testbed_invoke", &pldapi.TransactionInput{ + TransactionBase: pldapi.TransactionBase{ + From: recipient1Name, + To: ¬oAddress, + Function: "prepareUnlock", + Data: toJSON(t, &types.UnlockParams{ + LockID: lockID, + From: recipient1Name, + Recipients: []*types.UnlockRecipient{{ + To: recipient2Name, + Amount: tktypes.Int64ToInt256(50), + }}, + Data: tktypes.HexBytes{}, + }), + }, + ABI: types.NotoABI, + }, true) + if rpcerr != nil { + require.NoError(t, rpcerr.Error()) + } + unlockParams := buildUnlock(noto, lockID, &invokeResult) + + log.L(ctx).Infof("Delegate lock to recipient2") + rpcerr = rpc.CallRPC(ctx, &invokeResult, "testbed_invoke", &pldapi.TransactionInput{ + TransactionBase: pldapi.TransactionBase{ + From: recipient1Name, + To: ¬oAddress, + Function: "delegateLock", + Data: toJSON(t, &types.DelegateLockParams{ + LockID: lockID, + Delegate: tktypes.MustEthAddress(recipient2Key.Verifier.Verifier), + }), + }, + ABI: types.NotoABI, + }, true) + if rpcerr != nil { + require.NoError(t, rpcerr.Error()) + } + + log.L(ctx).Infof("Unlock from recipient2") + tx := client.ForABI(ctx, noto.contractABI). + Public(). + From(recipient2Name). + To(¬oAddress). + Function("unlock"). + Inputs(unlockParams). + Send(). + Wait(3 * time.Second) + require.NoError(t, tx.Error()) + + coins = findLockedCoins(t, ctx, rpc, noto, notoAddress, nil) + require.Len(t, coins, 0) + coins = findAvailableCoins(t, ctx, rpc, noto, notoAddress, nil) + require.Len(t, coins, 2) assert.Equal(t, int64(50), coins[0].Data.Amount.Int().Int64()) - assert.Equal(t, notaryKey.Verifier.Verifier, coins[0].Data.Owner.String()) + assert.Equal(t, recipient2Key.Verifier.Verifier, coins[0].Data.Owner.String()) assert.Equal(t, int64(50), coins[1].Data.Amount.Int().Int64()) assert.Equal(t, recipient2Key.Verifier.Verifier, coins[1].Data.Owner.String()) } diff --git a/domains/noto/internal/noto/handler_approve_transfer.go b/domains/noto/internal/noto/handler_approve_transfer.go index e6433e2eb..1626a92c1 100644 --- a/domains/noto/internal/noto/handler_approve_transfer.go +++ b/domains/noto/internal/noto/handler_approve_transfer.go @@ -20,7 +20,6 @@ import ( "encoding/json" "github.com/hyperledger/firefly-common/pkg/i18n" - "github.com/hyperledger/firefly-signer/pkg/ethtypes" "github.com/kaleido-io/paladin/domains/noto/internal/msgs" "github.com/kaleido-io/paladin/domains/noto/pkg/types" "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" @@ -28,7 +27,6 @@ import ( "github.com/kaleido-io/paladin/toolkit/pkg/pldapi" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" - "github.com/kaleido-io/paladin/toolkit/pkg/solutils" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" ) @@ -60,22 +58,10 @@ func (h *approveHandler) Init(ctx context.Context, tx *types.ParsedTransaction, }, nil } -func (h *approveHandler) transferHash(ctx context.Context, tx *types.ParsedTransaction, params *types.ApproveParams) (ethtypes.HexBytes0xPrefix, error) { - inputs := make([]any, len(params.Inputs)) - for i, state := range params.Inputs { - inputs[i] = state.ID - } - outputs := make([]any, len(params.Outputs)) - for i, state := range params.Outputs { - outputs[i] = state.ID - } - return h.noto.encodeTransferMasked(ctx, tx.ContractAddress, inputs, outputs, params.Data) -} - func (h *approveHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, req *prototk.AssembleTransactionRequest) (*prototk.AssembleTransactionResponse, error) { params := tx.Params.(*types.ApproveParams) notary := tx.DomainConfig.NotaryLookup - transferHash, err := h.transferHash(ctx, tx, params) + transferHash, err := h.noto.encodeTransferMasked(ctx, tx.ContractAddress, params.Inputs, params.Outputs, params.Data) if err != nil { return nil, err } @@ -123,22 +109,29 @@ func (h *approveHandler) decodeStates(states []*pldapi.StateEncoded) []*prototk. func (h *approveHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { params := tx.Params.(*types.ApproveParams) - coins, err := h.noto.gatherCoins(ctx, h.decodeStates(params.Inputs), h.decodeStates(params.Outputs)) + inputs, err := h.noto.parseCoinList(ctx, "input", h.decodeStates(params.Inputs)) if err != nil { return nil, err } - if err := h.noto.validateTransferAmounts(ctx, coins); err != nil { + outputs, err := h.noto.parseCoinList(ctx, "output", h.decodeStates(params.Outputs)) + if err != nil { return nil, err } - if err := h.noto.validateOwners(ctx, tx, req, coins); err != nil { + + // Validate the amounts, and sender's ownership of the inputs + if err := h.noto.validateTransferAmounts(ctx, inputs, outputs); err != nil { + return nil, err + } + if err := h.noto.validateOwners(ctx, tx.Transaction.From, req, inputs.coins, inputs.states); err != nil { return nil, err } - transferHash, err := h.transferHash(ctx, tx, params) + // Notary checks the signature from the sender, then submits the transaction + transferHash, err := h.noto.encodeTransferMasked(ctx, tx.ContractAddress, params.Inputs, params.Outputs, params.Data) if err != nil { return nil, err } - if err := h.noto.validateApprovalSignature(ctx, req, transferHash); err != nil { + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, transferHash); err != nil { return nil, err } return &prototk.EndorseTransactionResponse{ @@ -146,9 +139,9 @@ func (h *approveHandler) Endorse(ctx context.Context, tx *types.ParsedTransactio }, nil } -func (h *approveHandler) baseLedgerApprove(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { +func (h *approveHandler) baseLedgerInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { inParams := tx.Params.(*types.ApproveParams) - transferHash, err := h.transferHash(ctx, tx, inParams) + transferHash, err := h.noto.encodeTransferMasked(ctx, tx.ContractAddress, inParams.Inputs, inParams.Outputs, inParams.Data) if err != nil { return nil, err } @@ -164,7 +157,7 @@ func (h *approveHandler) baseLedgerApprove(ctx context.Context, tx *types.Parsed } params := &NotoApproveTransferParams{ Delegate: inParams.Delegate, - TXHash: tktypes.HexBytes(transferHash), + TXHash: tktypes.Bytes32(transferHash), Signature: sender.Payload, Data: data, } @@ -178,7 +171,7 @@ func (h *approveHandler) baseLedgerApprove(ctx context.Context, tx *types.Parsed }, nil } -func (h *approveHandler) hookApprove(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { +func (h *approveHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { inParams := tx.Params.(*types.ApproveParams) fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) @@ -194,49 +187,37 @@ func (h *approveHandler) hookApprove(ctx context.Context, tx *types.ParsedTransa Sender: fromAddress, From: fromAddress, Delegate: inParams.Delegate, + Data: inParams.Data, Prepared: PreparedTransaction{ ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), EncodedCall: encodedCall, }, } - transactionType := prototk.PreparedTransaction_PUBLIC - functionABI := solutils.MustLoadBuild(notoHooksJSON).ABI.Functions()["onApproveTransfer"] - var paramsJSON []byte - - if tx.DomainConfig.PrivateAddress != nil { - transactionType = prototk.PreparedTransaction_PRIVATE - functionABI = penteInvokeABI("onApproveTransfer", functionABI.Inputs) - penteParams := &PenteInvokeParams{ - Group: tx.DomainConfig.PrivateGroup, - To: tx.DomainConfig.PrivateAddress, - Inputs: params, - } - paramsJSON, err = json.Marshal(penteParams) - } else { - // Note: public hooks aren't really useful except in testing, as they disclose everything - // TODO: remove this? - paramsJSON, err = json.Marshal(params) - } + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onApproveTransfer"], + params, + ) if err != nil { return nil, err } return &TransactionWrapper{ - transactionType: transactionType, + transactionType: mapPrepareTransactionType(transactionType), functionABI: functionABI, paramsJSON: paramsJSON, - contractAddress: &tx.DomainConfig.NotaryAddress, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, }, nil } func (h *approveHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { - baseTransaction, err := h.baseLedgerApprove(ctx, tx, req) + baseTransaction, err := h.baseLedgerInvoke(ctx, tx, req) if err != nil { return nil, err } - if tx.DomainConfig.NotaryType == types.NotaryTypePente { - hookTransaction, err := h.hookApprove(ctx, tx, req, baseTransaction) + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, tx, req, baseTransaction) if err != nil { return nil, err } diff --git a/domains/noto/internal/noto/handler_burn.go b/domains/noto/internal/noto/handler_burn.go index a376960b1..919d81732 100644 --- a/domains/noto/internal/noto/handler_burn.go +++ b/domains/noto/internal/noto/handler_burn.go @@ -27,7 +27,6 @@ import ( "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" - "github.com/kaleido-io/paladin/toolkit/pkg/solutils" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" ) @@ -47,12 +46,22 @@ func (h *burnHandler) ValidateParams(ctx context.Context, config *types.NotoPars return &BurnParams, nil } +func (h *burnHandler) checkAllowed(ctx context.Context, tx *types.ParsedTransaction) error { + if tx.DomainConfig.NotaryMode != types.NotaryModeBasic.Enum() { + return nil + } + if *tx.DomainConfig.Options.Basic.AllowBurn { + return nil + } + return i18n.NewError(ctx, msgs.MsgBurnNotAllowed) +} + func (h *burnHandler) Init(ctx context.Context, tx *types.ParsedTransaction, req *prototk.InitTransactionRequest) (*prototk.InitTransactionResponse, error) { notary := tx.DomainConfig.NotaryLookup - - if !tx.DomainConfig.AllowBurning { - return nil, i18n.NewError(ctx, msgs.MsgNoBurning) + if err := h.checkAllowed(ctx, tx); err != nil { + return nil, err } + return &prototk.InitTransactionResponse{ RequiredVerifiers: []*prototk.ResolveVerifierRequest{ { @@ -73,17 +82,20 @@ func (h *burnHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, params := tx.Params.(*types.BurnParams) notary := tx.DomainConfig.NotaryLookup - _, err := h.noto.findEthAddressVerifier(ctx, "notary", notary, req.ResolvedVerifiers) - if err != nil { - return nil, err - } fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) if err != nil { return nil, err } - inputCoins, inputStates, total, err := h.noto.prepareInputs(ctx, req.StateQueryContext, fromAddress, params.Amount) + inputStates, revert, err := h.noto.prepareInputs(ctx, req.StateQueryContext, fromAddress, params.Amount) if err != nil { + if revert { + message := err.Error() + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_REVERT, + RevertReason: &message, + }, nil + } return nil, err } infoStates, err := h.noto.prepareInfo(params.Data, []string{notary, tx.Transaction.From}) @@ -93,17 +105,17 @@ func (h *burnHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, var outputCoins []*types.NotoCoin var outputStates []*prototk.NewState - if total.Cmp(params.Amount.Int()) == 1 { - remainder := big.NewInt(0).Sub(total, params.Amount.Int()) - returnedCoins, returnedStates, err := h.noto.prepareOutputs(fromAddress, (*tktypes.HexUint256)(remainder), []string{notary, tx.Transaction.From}) + if inputStates.total.Cmp(params.Amount.Int()) == 1 { + remainder := big.NewInt(0).Sub(inputStates.total, params.Amount.Int()) + returnedStates, err := h.noto.prepareOutputs(fromAddress, (*tktypes.HexUint256)(remainder), []string{notary, tx.Transaction.From}) if err != nil { return nil, err } - outputCoins = append(outputCoins, returnedCoins...) - outputStates = append(outputStates, returnedStates...) + outputCoins = append(outputCoins, returnedStates.coins...) + outputStates = append(outputStates, returnedStates.states...) } - encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, inputCoins, outputCoins) + encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, inputStates.coins, outputCoins) if err != nil { return nil, err } @@ -111,7 +123,7 @@ func (h *burnHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, return &prototk.AssembleTransactionResponse{ AssemblyResult: prototk.AssembleTransactionResponse_OK, AssembledTransaction: &prototk.AssembledTransaction{ - InputStates: inputStates, + InputStates: inputStates.states, OutputStates: outputStates, InfoStates: infoStates, }, @@ -140,19 +152,33 @@ func (h *burnHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, func (h *burnHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { params := tx.Params.(*types.BurnParams) - coins, err := h.noto.gatherCoins(ctx, req.Inputs, req.Outputs) + if err := h.checkAllowed(ctx, tx); err != nil { + return nil, err + } + + inputs, err := h.noto.parseCoinList(ctx, "input", req.Inputs) if err != nil { return nil, err } - if err := h.noto.validateBurnAmounts(ctx, params, coins); err != nil { + outputs, err := h.noto.parseCoinList(ctx, "output", req.Outputs) + if err != nil { + return nil, err + } + + // Validate the amounts, and sender's ownership of the inputs + if err := h.noto.validateBurnAmounts(ctx, params, inputs, outputs); err != nil { return nil, err } - if err := h.noto.validateOwners(ctx, tx, req, coins); err != nil { + if err := h.noto.validateOwners(ctx, tx.Transaction.From, req, inputs.coins, inputs.states); err != nil { return nil, err } // Notary checks the signature from the sender, then submits the transaction - if err := h.noto.validateTransferSignature(ctx, tx, req, coins); err != nil { + encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, inputs.coins, outputs.coins) + if err != nil { + return nil, err + } + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, encodedTransfer); err != nil { return nil, err } return &prototk.EndorseTransactionResponse{ @@ -160,16 +186,7 @@ func (h *burnHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, }, nil } -func (h *burnHandler) baseLedgerBurn(ctx context.Context, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { - inputs := make([]string, len(req.InputStates)) - for i, state := range req.InputStates { - inputs[i] = state.Id - } - outputs := make([]string, len(req.OutputStates)) - for i, state := range req.OutputStates { - outputs[i] = state.Id - } - +func (h *burnHandler) baseLedgerInvoke(ctx context.Context, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { // Include the signature from the sender/notary // This is not verified on the base ledger, but can be verified by anyone with the unmasked state data sender := domain.FindAttestation("sender", req.AttestationResult) @@ -181,9 +198,9 @@ func (h *burnHandler) baseLedgerBurn(ctx context.Context, req *prototk.PrepareTr if err != nil { return nil, err } - params := &NotoTransferParams{ - Inputs: inputs, - Outputs: outputs, + params := &NotoBurnParams{ + Inputs: endorsableStateIDs(req.InputStates), + Outputs: endorsableStateIDs(req.OutputStates), Signature: sender.Payload, Data: data, } @@ -198,7 +215,7 @@ func (h *burnHandler) baseLedgerBurn(ctx context.Context, req *prototk.PrepareTr }, nil } -func (h *burnHandler) hookBurn(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { +func (h *burnHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { inParams := tx.Params.(*types.BurnParams) fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) @@ -214,49 +231,37 @@ func (h *burnHandler) hookBurn(ctx context.Context, tx *types.ParsedTransaction, Sender: fromAddress, From: fromAddress, Amount: inParams.Amount, + Data: inParams.Data, Prepared: PreparedTransaction{ ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), EncodedCall: encodedCall, }, } - transactionType := prototk.PreparedTransaction_PUBLIC - functionABI := solutils.MustLoadBuild(notoHooksJSON).ABI.Functions()["onBurn"] - var paramsJSON []byte - - if tx.DomainConfig.PrivateAddress != nil { - transactionType = prototk.PreparedTransaction_PRIVATE - functionABI = penteInvokeABI("onBurn", functionABI.Inputs) - penteParams := &PenteInvokeParams{ - Group: tx.DomainConfig.PrivateGroup, - To: tx.DomainConfig.PrivateAddress, - Inputs: params, - } - paramsJSON, err = json.Marshal(penteParams) - } else { - // Note: public hooks aren't really useful except in testing, as they disclose everything - // TODO: remove this? - paramsJSON, err = json.Marshal(params) - } + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onBurn"], + params, + ) if err != nil { return nil, err } return &TransactionWrapper{ - transactionType: transactionType, + transactionType: mapPrepareTransactionType(transactionType), functionABI: functionABI, paramsJSON: paramsJSON, - contractAddress: &tx.DomainConfig.NotaryAddress, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, }, nil } func (h *burnHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { - baseTransaction, err := h.baseLedgerBurn(ctx, req) + baseTransaction, err := h.baseLedgerInvoke(ctx, req) if err != nil { return nil, err } - if tx.DomainConfig.NotaryType == types.NotaryTypePente { - hookTransaction, err := h.hookBurn(ctx, tx, req, baseTransaction) + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, tx, req, baseTransaction) if err != nil { return nil, err } diff --git a/domains/noto/internal/noto/handler_delegate_lock.go b/domains/noto/internal/noto/handler_delegate_lock.go new file mode 100644 index 000000000..bb63bae67 --- /dev/null +++ b/domains/noto/internal/noto/handler_delegate_lock.go @@ -0,0 +1,256 @@ +/* + * Copyright © 2024 Kaleido, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package noto + +import ( + "context" + "encoding/json" + "math/big" + + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/kaleido-io/paladin/domains/noto/internal/msgs" + "github.com/kaleido-io/paladin/domains/noto/pkg/types" + "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" + "github.com/kaleido-io/paladin/toolkit/pkg/pldapi" + "github.com/kaleido-io/paladin/toolkit/pkg/prototk" + "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" + "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" + "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" +) + +type delegateLockHandler struct { + noto *Noto +} + +func (h *delegateLockHandler) ValidateParams(ctx context.Context, config *types.NotoParsedConfig, params string) (interface{}, error) { + var delegateParams types.DelegateLockParams + if err := json.Unmarshal([]byte(params), &delegateParams); err != nil { + return nil, err + } + if delegateParams.LockID.IsZero() { + return nil, i18n.NewError(ctx, msgs.MsgParameterRequired, "lockId") + } + if delegateParams.Delegate.IsZero() { + return nil, i18n.NewError(ctx, msgs.MsgInvalidDelegate, delegateParams.Delegate) + } + return &delegateParams, nil +} + +func (h *delegateLockHandler) Init(ctx context.Context, tx *types.ParsedTransaction, req *prototk.InitTransactionRequest) (*prototk.InitTransactionResponse, error) { + return &prototk.InitTransactionResponse{ + RequiredVerifiers: []*prototk.ResolveVerifierRequest{ + { + Lookup: tx.Transaction.From, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + }, + }, + }, nil +} + +func (h *delegateLockHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, req *prototk.AssembleTransactionRequest) (*prototk.AssembleTransactionResponse, error) { + params := tx.Params.(*types.DelegateLockParams) + notary := tx.DomainConfig.NotaryLookup + + fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + + // Requester must own the locked states (only search for the first one) + lockedInputs, revert, err := h.noto.prepareLockedInputs(ctx, req.StateQueryContext, params.LockID, fromAddress, big.NewInt(1)) + if err != nil { + if revert { + message := err.Error() + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_REVERT, + RevertReason: &message, + }, nil + } + return nil, err + } + + infoStates, err := h.noto.prepareInfo(params.Data, []string{notary, tx.Transaction.From}) + if err != nil { + return nil, err + } + lockState, err := h.noto.prepareLockInfo(params.LockID, fromAddress, params.Delegate, []string{notary, tx.Transaction.From}) + if err != nil { + return nil, err + } + infoStates = append(infoStates, lockState) + + // This approval may leak the requesting signature on-chain, as all the inputs are visible on-chain + // TODO: possibly we should be signing a different payload here + encodedApproval, err := h.noto.encodeDelegateLock(ctx, tx.ContractAddress, params.LockID, params.Delegate, params.Data) + if err != nil { + return nil, err + } + + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_OK, + AssembledTransaction: &prototk.AssembledTransaction{ + ReadStates: lockedInputs.states, + InfoStates: infoStates, + }, + AttestationPlan: []*prototk.AttestationRequest{ + // Sender confirms the initial request with a signature + { + Name: "sender", + AttestationType: prototk.AttestationType_SIGN, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + PayloadType: signpayloads.OPAQUE_TO_RSV, + Payload: encodedApproval, + Parties: []string{req.Transaction.From}, + }, + // Notary will endorse the assembled transaction (by submitting to the ledger) + { + Name: "notary", + AttestationType: prototk.AttestationType_ENDORSE, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Parties: []string{notary}, + }, + }, + }, nil +} + +func (h *delegateLockHandler) decodeStates(states []*pldapi.StateEncoded) []*prototk.EndorsableState { + result := make([]*prototk.EndorsableState, len(states)) + for i, state := range states { + result[i] = &prototk.EndorsableState{ + Id: state.ID.String(), + SchemaId: state.Schema.String(), + StateDataJson: tktypes.RawJSON(state.Data).String(), + } + } + return result +} + +func (h *delegateLockHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { + params := tx.Params.(*types.DelegateLockParams) + inputs, err := h.noto.parseCoinList(ctx, "read", req.Reads) + if err != nil { + return nil, err + } + + // Sender must specify at least one locked state, to show that they own the lock + if len(inputs.lockedCoins) == 0 { + return nil, i18n.NewError(ctx, msgs.MsgNoStatesSpecified) + } + if err := h.noto.validateLockOwners(ctx, tx.Transaction.From, req.ResolvedVerifiers, inputs.lockedCoins, inputs.lockedStates); err != nil { + return nil, err + } + + // Notary checks the signature from the sender, then submits the transaction + encodedApproval, err := h.noto.encodeDelegateLock(ctx, tx.ContractAddress, params.LockID, params.Delegate, params.Data) + if err != nil { + return nil, err + } + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, encodedApproval); err != nil { + return nil, err + } + return &prototk.EndorseTransactionResponse{ + EndorsementResult: prototk.EndorseTransactionResponse_ENDORSER_SUBMIT, + }, nil +} + +func (h *delegateLockHandler) baseLedgerInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.DelegateLockParams) + + sender := domain.FindAttestation("sender", req.AttestationResult) + if sender == nil { + return nil, i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") + } + + data, err := h.noto.encodeTransactionData(ctx, req.Transaction, req.InfoStates) + if err != nil { + return nil, err + } + params := &NotoDelegateLockParams{ + LockID: inParams.LockID, + Delegate: inParams.Delegate, + Signature: sender.Payload, + Data: data, + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + return &TransactionWrapper{ + functionABI: h.noto.contractABI.Functions()["delegateLock"], + paramsJSON: paramsJSON, + }, nil +} + +func (h *delegateLockHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.DelegateLockParams) + + fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + + encodedCall, err := baseTransaction.encode(ctx) + if err != nil { + return nil, err + } + params := &ApproveUnlockHookParams{ + Sender: fromAddress, + LockID: inParams.LockID, + Delegate: inParams.Delegate, + Data: inParams.Data, + Prepared: PreparedTransaction{ + ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), + EncodedCall: encodedCall, + }, + } + + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onDelegateLock"], + params, + ) + if err != nil { + return nil, err + } + + return &TransactionWrapper{ + transactionType: mapPrepareTransactionType(transactionType), + functionABI: functionABI, + paramsJSON: paramsJSON, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, + }, nil +} + +func (h *delegateLockHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { + baseTransaction, err := h.baseLedgerInvoke(ctx, tx, req) + if err != nil { + return nil, err + } + + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, tx, req, baseTransaction) + if err != nil { + return nil, err + } + return hookTransaction.prepare(nil) + } + + return baseTransaction.prepare(nil) +} diff --git a/domains/noto/internal/noto/handler_lock.go b/domains/noto/internal/noto/handler_lock.go new file mode 100644 index 000000000..041d12762 --- /dev/null +++ b/domains/noto/internal/noto/handler_lock.go @@ -0,0 +1,319 @@ +/* + * Copyright © 2024 Kaleido, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package noto + +import ( + "context" + "encoding/json" + "math/big" + + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/kaleido-io/paladin/domains/noto/internal/msgs" + "github.com/kaleido-io/paladin/domains/noto/pkg/types" + "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" + "github.com/kaleido-io/paladin/toolkit/pkg/prototk" + "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" + "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" + "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" +) + +type lockHandler struct { + noto *Noto +} + +func (h *lockHandler) ValidateParams(ctx context.Context, config *types.NotoParsedConfig, params string) (interface{}, error) { + var lockParams types.LockParams + if err := json.Unmarshal([]byte(params), &lockParams); err != nil { + return nil, err + } + if lockParams.Amount == nil || lockParams.Amount.Int().Sign() != 1 { + return nil, i18n.NewError(ctx, msgs.MsgParameterGreaterThanZero, "amount") + } + return &lockParams, nil +} + +func (h *lockHandler) checkAllowed(ctx context.Context, tx *types.ParsedTransaction) error { + if tx.DomainConfig.NotaryMode != types.NotaryModeBasic.Enum() { + return nil + } + if *tx.DomainConfig.Options.Basic.AllowLock { + return nil + } + return i18n.NewError(ctx, msgs.MsgLockNotAllowed) +} + +func (h *lockHandler) Init(ctx context.Context, tx *types.ParsedTransaction, req *prototk.InitTransactionRequest) (*prototk.InitTransactionResponse, error) { + notary := tx.DomainConfig.NotaryLookup + if err := h.checkAllowed(ctx, tx); err != nil { + return nil, err + } + + return &prototk.InitTransactionResponse{ + RequiredVerifiers: h.noto.ethAddressVerifiers(notary, tx.Transaction.From), + }, nil +} + +func (h *lockHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, req *prototk.AssembleTransactionRequest) (*prototk.AssembleTransactionResponse, error) { + params := tx.Params.(*types.LockParams) + notary := tx.DomainConfig.NotaryLookup + + _, err := h.noto.findEthAddressVerifier(ctx, "notary", notary, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + + inputStates, revert, err := h.noto.prepareInputs(ctx, req.StateQueryContext, fromAddress, params.Amount) + if err != nil { + if revert { + message := err.Error() + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_REVERT, + RevertReason: &message, + }, nil + } + return nil, err + } + + lockID := tktypes.RandBytes32() + lockedOutputStates, err := h.noto.prepareLockedOutputs(lockID, fromAddress, params.Amount, []string{notary, tx.Transaction.From}) + if err != nil { + return nil, err + } + + unlockedOutputStates := &preparedOutputs{} + if inputStates.total.Cmp(params.Amount.Int()) == 1 { + remainder := big.NewInt(0).Sub(inputStates.total, params.Amount.Int()) + returnedStates, err := h.noto.prepareOutputs(fromAddress, (*tktypes.HexUint256)(remainder), []string{notary, tx.Transaction.From}) + if err != nil { + return nil, err + } + unlockedOutputStates.coins = append(unlockedOutputStates.coins, returnedStates.coins...) + unlockedOutputStates.states = append(unlockedOutputStates.states, returnedStates.states...) + } + + infoStates, err := h.noto.prepareInfo(params.Data, []string{notary, tx.Transaction.From}) + if err != nil { + return nil, err + } + lockState, err := h.noto.prepareLockInfo(lockID, fromAddress, nil, []string{notary, tx.Transaction.From}) + if err != nil { + return nil, err + } + infoStates = append(infoStates, lockState) + + encodedLock, err := h.noto.encodeLock(ctx, tx.ContractAddress, inputStates.coins, unlockedOutputStates.coins, lockedOutputStates.coins) + if err != nil { + return nil, err + } + + var outputStates []*prototk.NewState + outputStates = append(outputStates, lockedOutputStates.states...) + outputStates = append(outputStates, unlockedOutputStates.states...) + + attestation := []*prototk.AttestationRequest{ + // Sender confirms the initial request with a signature + { + Name: "sender", + AttestationType: prototk.AttestationType_SIGN, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Payload: encodedLock, + PayloadType: signpayloads.OPAQUE_TO_RSV, + Parties: []string{req.Transaction.From}, + }, + // Notary will endorse the assembled transaction (by submitting to the ledger) + { + Name: "notary", + AttestationType: prototk.AttestationType_ENDORSE, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Parties: []string{notary}, + }, + } + + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_OK, + AssembledTransaction: &prototk.AssembledTransaction{ + InputStates: inputStates.states, + OutputStates: outputStates, + InfoStates: infoStates, + }, + AttestationPlan: attestation, + }, nil +} + +func (h *lockHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { + if err := h.checkAllowed(ctx, tx); err != nil { + return nil, err + } + + inputs, err := h.noto.parseCoinList(ctx, "input", req.Inputs) + if err != nil { + return nil, err + } + outputs, err := h.noto.parseCoinList(ctx, "output", req.Outputs) + if err != nil { + return nil, err + } + + // Validate the amounts, and sender's ownership of the inputs and locked outputs + if err := h.noto.validateLockAmounts(ctx, inputs, outputs); err != nil { + return nil, err + } + if err := h.noto.validateOwners(ctx, tx.Transaction.From, req, inputs.coins, inputs.states); err != nil { + return nil, err + } + if err := h.noto.validateLockOwners(ctx, tx.Transaction.From, req.ResolvedVerifiers, outputs.lockedCoins, outputs.lockedStates); err != nil { + return nil, err + } + + // Notary checks the signature from the sender, then submits the transaction + encodedLock, err := h.noto.encodeLock(ctx, tx.ContractAddress, inputs.coins, outputs.coins, outputs.lockedCoins) + if err != nil { + return nil, err + } + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, encodedLock); err != nil { + return nil, err + } + return &prototk.EndorseTransactionResponse{ + EndorsementResult: prototk.EndorseTransactionResponse_ENDORSER_SUBMIT, + }, nil +} + +func (h *lockHandler) baseLedgerInvoke(ctx context.Context, lockID tktypes.Bytes32, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { + inputs := make([]string, len(req.InputStates)) + for i, state := range req.InputStates { + inputs[i] = state.Id + } + + lockedOutput, err := tktypes.ParseBytes32Ctx(ctx, req.OutputStates[0].Id) + if err != nil { + return nil, err + } + + remainderOutputs := make([]string, len(req.OutputStates)-1) + for i, state := range req.OutputStates[1:] { + remainderOutputs[i] = state.Id + } + + // Include the signature from the sender + // This is not verified on the base ledger, but can be verified by anyone with the unmasked state data + lockSignature := domain.FindAttestation("sender", req.AttestationResult) + if lockSignature == nil { + return nil, i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") + } + + data, err := h.noto.encodeTransactionData(ctx, req.Transaction, req.InfoStates) + if err != nil { + return nil, err + } + params := &NotoLockParams{ + LockID: lockID, + Inputs: inputs, + Outputs: remainderOutputs, + LockedOutputs: []string{lockedOutput.String()}, + Signature: lockSignature.Payload, + Data: data, + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + return &TransactionWrapper{ + functionABI: h.noto.contractABI.Functions()["lock"], + paramsJSON: paramsJSON, + }, nil +} + +func (h *lockHandler) hookInvoke(ctx context.Context, lockID tktypes.Bytes32, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.LockParams) + + fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + + encodedCall, err := baseTransaction.encode(ctx) + if err != nil { + return nil, err + } + params := &LockHookParams{ + Sender: fromAddress, + LockID: lockID, + From: fromAddress, + Amount: inParams.Amount, + Data: inParams.Data, + Prepared: PreparedTransaction{ + ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), + EncodedCall: encodedCall, + }, + } + + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onLock"], + params, + ) + if err != nil { + return nil, err + } + + return &TransactionWrapper{ + transactionType: mapPrepareTransactionType(transactionType), + functionABI: functionABI, + paramsJSON: paramsJSON, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, + }, nil +} + +func (h *lockHandler) extractLockID(ctx context.Context, req *prototk.PrepareTransactionRequest) (tktypes.Bytes32, error) { + lockStates := h.noto.filterSchema(req.InfoStates, []string{h.noto.lockInfoSchema.Id}) + if len(lockStates) == 1 { + lock, err := h.noto.unmarshalLock(lockStates[0].StateDataJson) + if err != nil { + return tktypes.Bytes32{}, err + } + return lock.LockID, nil + } + return tktypes.Bytes32{}, i18n.NewError(ctx, msgs.MsgLockIDNotFound) +} + +func (h *lockHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { + lockID, err := h.extractLockID(ctx, req) + if err != nil { + return nil, err + } + + baseTransaction, err := h.baseLedgerInvoke(ctx, lockID, tx, req) + if err != nil { + return nil, err + } + + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, lockID, tx, req, baseTransaction) + if err != nil { + return nil, err + } + return hookTransaction.prepare(nil) + } + + return baseTransaction.prepare(nil) +} diff --git a/domains/noto/internal/noto/handler_mint.go b/domains/noto/internal/noto/handler_mint.go index 93a146033..86acfff07 100644 --- a/domains/noto/internal/noto/handler_mint.go +++ b/domains/noto/internal/noto/handler_mint.go @@ -26,7 +26,6 @@ import ( "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" - "github.com/kaleido-io/paladin/toolkit/pkg/solutils" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" ) @@ -49,13 +48,26 @@ func (h *mintHandler) ValidateParams(ctx context.Context, config *types.NotoPars return &mintParams, nil } +func (h *mintHandler) checkAllowed(ctx context.Context, tx *types.ParsedTransaction, from string) error { + if tx.DomainConfig.NotaryMode != types.NotaryModeBasic.Enum() { + return nil + } + if !*tx.DomainConfig.Options.Basic.RestrictMint { + return nil + } + if from == tx.DomainConfig.NotaryLookup { + return nil + } + return i18n.NewError(ctx, msgs.MsgMintOnlyNotary, tx.DomainConfig.NotaryLookup, from) +} + func (h *mintHandler) Init(ctx context.Context, tx *types.ParsedTransaction, req *prototk.InitTransactionRequest) (*prototk.InitTransactionResponse, error) { params := tx.Params.(*types.MintParams) notary := tx.DomainConfig.NotaryLookup - - if tx.DomainConfig.RestrictMinting && req.Transaction.From != notary { - return nil, i18n.NewError(ctx, msgs.MsgMintOnlyNotary, notary, req.Transaction.From) + if err := h.checkAllowed(ctx, tx, req.Transaction.From); err != nil { + return nil, err } + return &prototk.InitTransactionResponse{ RequiredVerifiers: []*prototk.ResolveVerifierRequest{ { @@ -81,25 +93,21 @@ func (h *mintHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, params := tx.Params.(*types.MintParams) notary := tx.DomainConfig.NotaryLookup - _, err := h.noto.findEthAddressVerifier(ctx, "notary", notary, req.ResolvedVerifiers) - if err != nil { - return nil, err - } toAddress, err := h.noto.findEthAddressVerifier(ctx, "to", params.To, req.ResolvedVerifiers) if err != nil { return nil, err } - outputCoins, outputStates, err := h.noto.prepareOutputs(toAddress, params.Amount, []string{notary, params.To}) + outputStates, err := h.noto.prepareOutputs(toAddress, params.Amount, []string{notary, params.To}) if err != nil { return nil, err } - encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, nil, outputCoins) + infoStates, err := h.noto.prepareInfo(params.Data, []string{notary, params.To}) if err != nil { return nil, err } - infoStates, err := h.noto.prepareInfo(params.Data, []string{notary, params.To}) + encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, nil, outputStates.coins) if err != nil { return nil, err } @@ -107,7 +115,7 @@ func (h *mintHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, return &prototk.AssembleTransactionResponse{ AssemblyResult: prototk.AssembleTransactionResponse_OK, AssembledTransaction: &prototk.AssembledTransaction{ - OutputStates: outputStates, + OutputStates: outputStates.states, InfoStates: infoStates, }, AttestationPlan: []*prototk.AttestationRequest{ @@ -135,16 +143,30 @@ func (h *mintHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, func (h *mintHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { params := tx.Params.(*types.MintParams) - coins, err := h.noto.gatherCoins(ctx, req.Inputs, req.Outputs) + if err := h.checkAllowed(ctx, tx, req.Transaction.From); err != nil { + return nil, err + } + + inputs, err := h.noto.parseCoinList(ctx, "input", req.Inputs) if err != nil { return nil, err } - if err := h.noto.validateMintAmounts(ctx, params, coins); err != nil { + outputs, err := h.noto.parseCoinList(ctx, "output", req.Outputs) + if err != nil { + return nil, err + } + + // Validate the amounts + if err := h.noto.validateMintAmounts(ctx, params, inputs, outputs); err != nil { return nil, err } // Notary checks the signature from the sender, then submits the transaction - if err := h.noto.validateTransferSignature(ctx, tx, req, coins); err != nil { + encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, nil, outputs.coins) + if err != nil { + return nil, err + } + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, encodedTransfer); err != nil { return nil, err } return &prototk.EndorseTransactionResponse{ @@ -152,12 +174,7 @@ func (h *mintHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, }, nil } -func (h *mintHandler) baseLedgerMint(ctx context.Context, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { - outputs := make([]string, len(req.OutputStates)) - for i, state := range req.OutputStates { - outputs[i] = state.Id - } - +func (h *mintHandler) baseLedgerInvoke(ctx context.Context, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { // Include the signature from the sender/notary // This is not verified on the base ledger, but can be verified by anyone with the unmasked state data sender := domain.FindAttestation("sender", req.AttestationResult) @@ -170,7 +187,7 @@ func (h *mintHandler) baseLedgerMint(ctx context.Context, req *prototk.PrepareTr return nil, err } params := &NotoMintParams{ - Outputs: outputs, + Outputs: endorsableStateIDs(req.OutputStates), Signature: sender.Payload, Data: data, } @@ -185,7 +202,7 @@ func (h *mintHandler) baseLedgerMint(ctx context.Context, req *prototk.PrepareTr }, nil } -func (h *mintHandler) hookMint(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { +func (h *mintHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { inParams := tx.Params.(*types.MintParams) fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) @@ -205,49 +222,37 @@ func (h *mintHandler) hookMint(ctx context.Context, tx *types.ParsedTransaction, Sender: fromAddress, To: toAddress, Amount: inParams.Amount, + Data: inParams.Data, Prepared: PreparedTransaction{ ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), EncodedCall: encodedCall, }, } - transactionType := prototk.PreparedTransaction_PUBLIC - functionABI := solutils.MustLoadBuild(notoHooksJSON).ABI.Functions()["onMint"] - var paramsJSON []byte - - if tx.DomainConfig.PrivateAddress != nil { - transactionType = prototk.PreparedTransaction_PRIVATE - functionABI = penteInvokeABI("onMint", functionABI.Inputs) - penteParams := &PenteInvokeParams{ - Group: tx.DomainConfig.PrivateGroup, - To: tx.DomainConfig.PrivateAddress, - Inputs: params, - } - paramsJSON, err = json.Marshal(penteParams) - } else { - // Note: public hooks aren't really useful except in testing, as they disclose everything - // TODO: remove this? - paramsJSON, err = json.Marshal(params) - } + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onMint"], + params, + ) if err != nil { return nil, err } return &TransactionWrapper{ - transactionType: transactionType, + transactionType: mapPrepareTransactionType(transactionType), functionABI: functionABI, paramsJSON: paramsJSON, - contractAddress: &tx.DomainConfig.NotaryAddress, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, }, nil } func (h *mintHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { - baseTransaction, err := h.baseLedgerMint(ctx, req) + baseTransaction, err := h.baseLedgerInvoke(ctx, req) if err != nil { return nil, err } - if tx.DomainConfig.NotaryType == types.NotaryTypePente { - hookTransaction, err := h.hookMint(ctx, tx, req, baseTransaction) + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, tx, req, baseTransaction) if err != nil { return nil, err } diff --git a/domains/noto/internal/noto/handler_prepare_unlock.go b/domains/noto/internal/noto/handler_prepare_unlock.go new file mode 100644 index 000000000..f1b252d95 --- /dev/null +++ b/domains/noto/internal/noto/handler_prepare_unlock.go @@ -0,0 +1,215 @@ +/* + * Copyright © 2024 Kaleido, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package noto + +import ( + "context" + "encoding/json" + + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/kaleido-io/paladin/domains/noto/internal/msgs" + "github.com/kaleido-io/paladin/domains/noto/pkg/types" + "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" + "github.com/kaleido-io/paladin/toolkit/pkg/prototk" + "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" + "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" + "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" +) + +type prepareUnlockHandler struct { + unlockCommon +} + +func (h *prepareUnlockHandler) ValidateParams(ctx context.Context, config *types.NotoParsedConfig, params string) (interface{}, error) { + var unlockParams types.UnlockParams + err := json.Unmarshal([]byte(params), &unlockParams) + if err == nil { + err = h.validateParams(ctx, &unlockParams) + } + return &unlockParams, err +} + +func (h *prepareUnlockHandler) Init(ctx context.Context, tx *types.ParsedTransaction, req *prototk.InitTransactionRequest) (*prototk.InitTransactionResponse, error) { + params := tx.Params.(*types.UnlockParams) + return h.init(ctx, tx, params) +} + +func (h *prepareUnlockHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, req *prototk.AssembleTransactionRequest) (*prototk.AssembleTransactionResponse, error) { + params := tx.Params.(*types.UnlockParams) + notary := tx.DomainConfig.NotaryLookup + + res, states, err := h.assembleStates(ctx, tx, params, req) + if err != nil || res.AssemblyResult != prototk.AssembleTransactionResponse_OK { + return res, err + } + + assembledTransaction := &prototk.AssembledTransaction{} + assembledTransaction.ReadStates = states.lockedInputs.states + assembledTransaction.InfoStates = states.info + assembledTransaction.InfoStates = append(assembledTransaction.InfoStates, states.outputs.states...) + assembledTransaction.InfoStates = append(assembledTransaction.InfoStates, states.lockedOutputs.states...) + + encodedUnlock, err := h.noto.encodeUnlock(ctx, tx.ContractAddress, states.lockedInputs.coins, states.lockedOutputs.coins, states.outputs.coins) + if err != nil { + return nil, err + } + + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_OK, + AssembledTransaction: assembledTransaction, + AttestationPlan: []*prototk.AttestationRequest{ + // Sender confirms the initial request with a signature + { + Name: "sender", + AttestationType: prototk.AttestationType_SIGN, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Payload: encodedUnlock, + PayloadType: signpayloads.OPAQUE_TO_RSV, + Parties: []string{req.Transaction.From}, + }, + // Notary will endorse the assembled transaction (by submitting to the ledger) + { + Name: "notary", + AttestationType: prototk.AttestationType_ENDORSE, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Parties: []string{notary}, + }, + }, + }, nil +} + +func (h *prepareUnlockHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { + params := tx.Params.(*types.UnlockParams) + lockedInputs := req.Reads + allOutputs := h.noto.filterSchema(req.Info, []string{h.noto.coinSchema.Id, h.noto.lockedCoinSchema.Id}) + + inputs, err := h.noto.parseCoinList(ctx, "input", lockedInputs) + if err != nil { + return nil, err + } + outputs, err := h.noto.parseCoinList(ctx, "output", allOutputs) + if err != nil { + return nil, err + } + + return h.endorse(ctx, tx, params, req, inputs, outputs) +} + +func (h *prepareUnlockHandler) baseLedgerInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.UnlockParams) + + lockedInputs := req.ReadStates + outputs, lockedOutputs := h.noto.splitStates(req.InfoStates) + unlockHash, err := h.noto.encodeUnlockMasked(ctx, tx.ContractAddress, lockedInputs, lockedOutputs, outputs, inParams.Data) + if err != nil { + return nil, err + } + + // Include the signature from the sender + // This is not verified on the base ledger, but can be verified by anyone with the unmasked state data + sender := domain.FindAttestation("sender", req.AttestationResult) + if sender == nil { + return nil, i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") + } + + data, err := h.noto.encodeTransactionData(ctx, req.Transaction, req.InfoStates) + if err != nil { + return nil, err + } + params := &NotoPrepareUnlockParams{ + LockID: inParams.LockID, + LockedInputs: endorsableStateIDs(lockedInputs), + UnlockHash: tktypes.Bytes32(unlockHash), + Signature: sender.Payload, + Data: data, + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + return &TransactionWrapper{ + functionABI: h.noto.contractABI.Functions()["prepareUnlock"], + paramsJSON: paramsJSON, + }, nil +} + +func (h *prepareUnlockHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.UnlockParams) + + fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + recipients := make([]*ResolvedUnlockRecipient, len(inParams.Recipients)) + for i, entry := range inParams.Recipients { + to, err := h.noto.findEthAddressVerifier(ctx, "to", entry.To, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + recipients[i] = &ResolvedUnlockRecipient{To: to, Amount: entry.Amount} + } + + encodedCall, err := baseTransaction.encode(ctx) + if err != nil { + return nil, err + } + params := &UnlockHookParams{ + Sender: fromAddress, + LockID: inParams.LockID, + Recipients: recipients, + Data: inParams.Data, + Prepared: PreparedTransaction{ + ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), + EncodedCall: encodedCall, + }, + } + + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onPrepareUnlock"], + params, + ) + if err != nil { + return nil, err + } + + return &TransactionWrapper{ + transactionType: mapPrepareTransactionType(transactionType), + functionABI: functionABI, + paramsJSON: paramsJSON, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, + }, nil +} + +func (h *prepareUnlockHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { + baseTransaction, err := h.baseLedgerInvoke(ctx, tx, req) + if err != nil { + return nil, err + } + + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, tx, req, baseTransaction) + if err != nil { + return nil, err + } + return hookTransaction.prepare(nil) + } + + return baseTransaction.prepare(nil) +} diff --git a/domains/noto/internal/noto/handler_transfer.go b/domains/noto/internal/noto/handler_transfer.go index e3a0f2c77..89510fb44 100644 --- a/domains/noto/internal/noto/handler_transfer.go +++ b/domains/noto/internal/noto/handler_transfer.go @@ -27,7 +27,6 @@ import ( "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" - "github.com/kaleido-io/paladin/toolkit/pkg/solutils" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" ) @@ -79,10 +78,6 @@ func (h *transferHandler) Assemble(ctx context.Context, tx *types.ParsedTransact params := tx.Params.(*types.TransferParams) notary := tx.DomainConfig.NotaryLookup - _, err := h.noto.findEthAddressVerifier(ctx, "notary", notary, req.ResolvedVerifiers) - if err != nil { - return nil, err - } fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) if err != nil { return nil, err @@ -92,11 +87,18 @@ func (h *transferHandler) Assemble(ctx context.Context, tx *types.ParsedTransact return nil, err } - inputCoins, inputStates, total, err := h.noto.prepareInputs(ctx, req.StateQueryContext, fromAddress, params.Amount) + inputStates, revert, err := h.noto.prepareInputs(ctx, req.StateQueryContext, fromAddress, params.Amount) if err != nil { + if revert { + message := err.Error() + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_REVERT, + RevertReason: &message, + }, nil + } return nil, err } - outputCoins, outputStates, err := h.noto.prepareOutputs(toAddress, params.Amount, []string{notary, tx.Transaction.From, params.To}) + outputStates, err := h.noto.prepareOutputs(toAddress, params.Amount, []string{notary, tx.Transaction.From, params.To}) if err != nil { return nil, err } @@ -105,20 +107,20 @@ func (h *transferHandler) Assemble(ctx context.Context, tx *types.ParsedTransact return nil, err } - if total.Cmp(params.Amount.Int()) == 1 { - remainder := big.NewInt(0).Sub(total, params.Amount.Int()) - returnedCoins, returnedStates, err := h.noto.prepareOutputs(fromAddress, (*tktypes.HexUint256)(remainder), []string{notary, tx.Transaction.From}) + if inputStates.total.Cmp(params.Amount.Int()) == 1 { + remainder := big.NewInt(0).Sub(inputStates.total, params.Amount.Int()) + returnedStates, err := h.noto.prepareOutputs(fromAddress, (*tktypes.HexUint256)(remainder), []string{notary, tx.Transaction.From}) if err != nil { return nil, err } - outputCoins = append(outputCoins, returnedCoins...) - outputStates = append(outputStates, returnedStates...) + outputStates.coins = append(outputStates.coins, returnedStates.coins...) + outputStates.states = append(outputStates.states, returnedStates.states...) } var attestation []*prototk.AttestationRequest switch tx.DomainConfig.Variant { case types.NotoVariantDefault: - encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, inputCoins, outputCoins) + encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, inputStates.coins, outputStates.coins) if err != nil { return nil, err } @@ -142,26 +144,6 @@ func (h *transferHandler) Assemble(ctx context.Context, tx *types.ParsedTransact Parties: []string{notary}, }, } - case types.NotoVariantSelfSubmit: - attestation = []*prototk.AttestationRequest{ - // Notary will endorse the assembled transaction (by providing a signature) - { - Name: "notary", - AttestationType: prototk.AttestationType_ENDORSE, - Algorithm: algorithms.ECDSA_SECP256K1, - VerifierType: verifiers.ETH_ADDRESS, - PayloadType: signpayloads.OPAQUE_TO_RSV, - Parties: []string{notary}, - }, - // Sender will endorse the assembled transaction (by submitting to the ledger) - { - Name: "sender", - AttestationType: prototk.AttestationType_ENDORSE, - Algorithm: algorithms.ECDSA_SECP256K1, - VerifierType: verifiers.ETH_ADDRESS, - Parties: []string{req.Transaction.From}, - }, - } default: return nil, i18n.NewError(ctx, msgs.MsgUnknownDomainVariant, tx.DomainConfig.Variant) } @@ -169,8 +151,8 @@ func (h *transferHandler) Assemble(ctx context.Context, tx *types.ParsedTransact return &prototk.AssembleTransactionResponse{ AssemblyResult: prototk.AssembleTransactionResponse_OK, AssembledTransaction: &prototk.AssembledTransaction{ - InputStates: inputStates, - OutputStates: outputStates, + InputStates: inputStates.states, + OutputStates: outputStates.states, InfoStates: infoStates, }, AttestationPlan: attestation, @@ -178,14 +160,20 @@ func (h *transferHandler) Assemble(ctx context.Context, tx *types.ParsedTransact } func (h *transferHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { - coins, err := h.noto.gatherCoins(ctx, req.Inputs, req.Outputs) + inputs, err := h.noto.parseCoinList(ctx, "input", req.Inputs) + if err != nil { + return nil, err + } + outputs, err := h.noto.parseCoinList(ctx, "output", req.Outputs) if err != nil { return nil, err } - if err := h.noto.validateTransferAmounts(ctx, coins); err != nil { + + // Validate the amounts, and sender's ownership of the inputs + if err := h.noto.validateTransferAmounts(ctx, inputs, outputs); err != nil { return nil, err } - if err := h.noto.validateOwners(ctx, tx, req, coins); err != nil { + if err := h.noto.validateOwners(ctx, tx.Transaction.From, req, inputs.coins, inputs.states); err != nil { return nil, err } @@ -193,43 +181,16 @@ func (h *transferHandler) Endorse(ctx context.Context, tx *types.ParsedTransacti case types.NotoVariantDefault: if req.EndorsementRequest.Name == "notary" { // Notary checks the signature from the sender, then submits the transaction - if err := h.noto.validateTransferSignature(ctx, tx, req, coins); err != nil { - return nil, err - } - return &prototk.EndorseTransactionResponse{ - EndorsementResult: prototk.EndorseTransactionResponse_ENDORSER_SUBMIT, - }, nil - } - case types.NotoVariantSelfSubmit: - if req.EndorsementRequest.Name == "notary" { - // Notary provides a signature for the assembled payload (to be verified on base ledger) - inputIDs := make([]interface{}, len(req.Inputs)) - outputIDs := make([]interface{}, len(req.Outputs)) - for i, state := range req.Inputs { - inputIDs[i] = state.Id - } - for i, state := range req.Outputs { - outputIDs[i] = state.Id - } - data, err := h.noto.encodeTransactionData(ctx, req.Transaction, req.Info) + encodedTransfer, err := h.noto.encodeTransferUnmasked(ctx, tx.ContractAddress, inputs.coins, outputs.coins) if err != nil { return nil, err } - encodedTransfer, err := h.noto.encodeTransferMasked(ctx, tx.ContractAddress, inputIDs, outputIDs, data) - if err != nil { + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, encodedTransfer); err != nil { return nil, err } return &prototk.EndorseTransactionResponse{ - EndorsementResult: prototk.EndorseTransactionResponse_SIGN, - Payload: encodedTransfer, + EndorsementResult: prototk.EndorseTransactionResponse_ENDORSER_SUBMIT, }, nil - } else if req.EndorsementRequest.Name == "sender" { - if req.EndorsementVerifier.Lookup == tx.Transaction.From { - // Sender submits the transaction - return &prototk.EndorseTransactionResponse{ - EndorsementResult: prototk.EndorseTransactionResponse_ENDORSER_SUBMIT, - }, nil - } } default: return nil, i18n.NewError(ctx, msgs.MsgUnknownDomainVariant, tx.DomainConfig.Variant) @@ -238,16 +199,7 @@ func (h *transferHandler) Endorse(ctx context.Context, tx *types.ParsedTransacti return nil, i18n.NewError(ctx, msgs.MsgUnrecognizedEndorsement, req.EndorsementRequest.Name) } -func (h *transferHandler) baseLedgerTransfer(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, withApproval bool) (*TransactionWrapper, error) { - inputs := make([]string, len(req.InputStates)) - for i, state := range req.InputStates { - inputs[i] = state.Id - } - outputs := make([]string, len(req.OutputStates)) - for i, state := range req.OutputStates { - outputs[i] = state.Id - } - +func (h *transferHandler) baseLedgerInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, withApproval bool) (*TransactionWrapper, error) { var signature *prototk.AttestationResult switch tx.DomainConfig.Variant { case types.NotoVariantDefault: @@ -257,12 +209,6 @@ func (h *transferHandler) baseLedgerTransfer(ctx context.Context, tx *types.Pars if signature == nil { return nil, i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") } - case types.NotoVariantSelfSubmit: - // Include the signature from the notary (will be verified on base ledger) - signature = domain.FindAttestation("notary", req.AttestationResult) - if signature == nil { - return nil, i18n.NewError(ctx, msgs.MsgAttestationNotFound, "notary") - } default: return nil, i18n.NewError(ctx, msgs.MsgUnknownDomainVariant, tx.DomainConfig.Variant) } @@ -272,8 +218,8 @@ func (h *transferHandler) baseLedgerTransfer(ctx context.Context, tx *types.Pars return nil, err } params := &NotoTransferParams{ - Inputs: inputs, - Outputs: outputs, + Inputs: endorsableStateIDs(req.InputStates), + Outputs: endorsableStateIDs(req.OutputStates), Signature: signature.Payload, Data: data, } @@ -291,7 +237,7 @@ func (h *transferHandler) baseLedgerTransfer(ctx context.Context, tx *types.Pars }, nil } -func (h *transferHandler) hookTransfer(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { +func (h *transferHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { inParams := tx.Params.(*types.TransferParams) fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) @@ -312,39 +258,27 @@ func (h *transferHandler) hookTransfer(ctx context.Context, tx *types.ParsedTran From: fromAddress, To: toAddress, Amount: inParams.Amount, + Data: inParams.Data, Prepared: PreparedTransaction{ ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), EncodedCall: encodedCall, }, } - transactionType := prototk.PreparedTransaction_PUBLIC - functionABI := solutils.MustLoadBuild(notoHooksJSON).ABI.Functions()["onTransfer"] - var paramsJSON []byte - - if tx.DomainConfig.PrivateAddress != nil { - transactionType = prototk.PreparedTransaction_PRIVATE - functionABI = penteInvokeABI("onTransfer", functionABI.Inputs) - penteParams := &PenteInvokeParams{ - Group: tx.DomainConfig.PrivateGroup, - To: tx.DomainConfig.PrivateAddress, - Inputs: params, - } - paramsJSON, err = json.Marshal(penteParams) - } else { - // Note: public hooks aren't really useful except in testing, as they disclose everything - // TODO: remove this? - paramsJSON, err = json.Marshal(params) - } + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onTransfer"], + params, + ) if err != nil { return nil, err } return &TransactionWrapper{ - transactionType: transactionType, + transactionType: mapPrepareTransactionType(transactionType), functionABI: functionABI, paramsJSON: paramsJSON, - contractAddress: &tx.DomainConfig.NotaryAddress, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, }, nil } @@ -381,24 +315,24 @@ func (h *transferHandler) Prepare(ctx context.Context, tx *types.ParsedTransacti // If preparing a transaction for later use, return metadata allowing it to be delegated to an approved party prepareApprovals := req.Transaction.Intent == prototk.TransactionSpecification_PREPARE_TRANSACTION - baseTransaction, err = h.baseLedgerTransfer(ctx, tx, req, false) + baseTransaction, err = h.baseLedgerInvoke(ctx, tx, req, false) if err != nil { return nil, err } if prepareApprovals { - withApprovalTransaction, err = h.baseLedgerTransfer(ctx, tx, req, true) + withApprovalTransaction, err = h.baseLedgerInvoke(ctx, tx, req, true) if err != nil { return nil, err } } - if tx.DomainConfig.NotaryType == types.NotaryTypePente { - hookTransaction, err = h.hookTransfer(ctx, tx, req, baseTransaction) + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err = h.hookInvoke(ctx, tx, req, baseTransaction) if err != nil { return nil, err } if prepareApprovals { - withApprovalHookTransaction, err = h.hookTransfer(ctx, tx, req, withApprovalTransaction) + withApprovalHookTransaction, err = h.hookInvoke(ctx, tx, req, withApprovalTransaction) if err != nil { return nil, err } diff --git a/domains/noto/internal/noto/handler_unlock.go b/domains/noto/internal/noto/handler_unlock.go new file mode 100644 index 000000000..7ddaa8baa --- /dev/null +++ b/domains/noto/internal/noto/handler_unlock.go @@ -0,0 +1,386 @@ +/* + * Copyright © 2024 Kaleido, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package noto + +import ( + "context" + "encoding/json" + "math/big" + + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/kaleido-io/paladin/domains/noto/internal/msgs" + "github.com/kaleido-io/paladin/domains/noto/pkg/types" + "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" + "github.com/kaleido-io/paladin/toolkit/pkg/prototk" + "github.com/kaleido-io/paladin/toolkit/pkg/signpayloads" + "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" + "github.com/kaleido-io/paladin/toolkit/pkg/verifiers" +) + +type unlockCommon struct { + noto *Noto +} + +type unlockHandler struct { + unlockCommon +} + +type unlockStates struct { + lockedInputs *preparedLockedInputs + lockedOutputs *preparedLockedOutputs + outputs *preparedOutputs + info []*prototk.NewState +} + +func (h *unlockCommon) validateParams(ctx context.Context, unlockParams *types.UnlockParams) error { + if unlockParams.LockID.IsZero() { + return i18n.NewError(ctx, msgs.MsgParameterRequired, "lockId") + } + if len(unlockParams.From) == 0 { + return i18n.NewError(ctx, msgs.MsgParameterRequired, "from") + } + if len(unlockParams.Recipients) == 0 { + return i18n.NewError(ctx, msgs.MsgParameterRequired, "recipients") + } + return nil +} + +func (h *unlockCommon) checkAllowed(ctx context.Context, tx *types.ParsedTransaction, from string) error { + if tx.DomainConfig.NotaryMode != types.NotaryModeBasic.Enum() { + return nil + } + if !*tx.DomainConfig.Options.Basic.RestrictUnlock { + return nil + } + + localNodeName, _ := h.noto.Callbacks.LocalNodeName(ctx, &prototk.LocalNodeNameRequest{}) + fromQualified, err := tktypes.PrivateIdentityLocator(from).FullyQualified(ctx, localNodeName.Name) + if err != nil { + return err + } + if tx.Transaction.From == fromQualified.String() { + return nil + } + return i18n.NewError(ctx, msgs.MsgUnlockOnlyCreator, tx.Transaction.From, from) +} + +func (h *unlockCommon) init(ctx context.Context, tx *types.ParsedTransaction, params *types.UnlockParams) (*prototk.InitTransactionResponse, error) { + notary := tx.DomainConfig.NotaryLookup + if err := h.checkAllowed(ctx, tx, params.From); err != nil { + return nil, err + } + + lookups := []string{notary, tx.Transaction.From, params.From} + for _, entry := range params.Recipients { + lookups = append(lookups, entry.To) + } + + return &prototk.InitTransactionResponse{ + RequiredVerifiers: h.noto.ethAddressVerifiers(lookups...), + }, nil +} + +func (h *unlockCommon) assembleStates(ctx context.Context, tx *types.ParsedTransaction, params *types.UnlockParams, req *prototk.AssembleTransactionRequest) (*prototk.AssembleTransactionResponse, *unlockStates, error) { + notary := tx.DomainConfig.NotaryLookup + + _, err := h.noto.findEthAddressVerifier(ctx, "notary", notary, req.ResolvedVerifiers) + if err != nil { + return nil, nil, err + } + fromAddress, err := h.noto.findEthAddressVerifier(ctx, "from", params.From, req.ResolvedVerifiers) + if err != nil { + return nil, nil, err + } + + requiredTotal := big.NewInt(0) + for _, entry := range params.Recipients { + requiredTotal = requiredTotal.Add(requiredTotal, entry.Amount.Int()) + } + + lockedInputStates, revert, err := h.noto.prepareLockedInputs(ctx, req.StateQueryContext, params.LockID, fromAddress, requiredTotal) + if err != nil { + if revert { + message := err.Error() + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_REVERT, + RevertReason: &message, + }, nil, nil + } + return nil, nil, err + } + + remainder := big.NewInt(0).Sub(lockedInputStates.total, requiredTotal) + unlockedOutputs, lockedOutputs, err := h.assembleUnlockOutputs(ctx, tx, params, req, fromAddress, remainder) + if err != nil { + return nil, nil, err + } + + infoStates, err := h.noto.prepareInfo(params.Data, []string{notary, params.From}) + if err != nil { + return nil, nil, err + } + lockState, err := h.noto.prepareLockInfo(params.LockID, fromAddress, nil, []string{notary, params.From}) + if err != nil { + return nil, nil, err + } + infoStates = append(infoStates, lockState) + + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_OK, + }, &unlockStates{ + lockedInputs: lockedInputStates, + lockedOutputs: lockedOutputs, + outputs: unlockedOutputs, + info: infoStates, + }, nil +} + +func (h *unlockCommon) assembleUnlockOutputs(ctx context.Context, tx *types.ParsedTransaction, params *types.UnlockParams, req *prototk.AssembleTransactionRequest, from *tktypes.EthAddress, remainder *big.Int) (*preparedOutputs, *preparedLockedOutputs, error) { + notary := tx.DomainConfig.NotaryLookup + + unlockedOutputs := &preparedOutputs{} + for _, entry := range params.Recipients { + toAddress, err := h.noto.findEthAddressVerifier(ctx, "to", entry.To, req.ResolvedVerifiers) + if err != nil { + return nil, nil, err + } + outputs, err := h.noto.prepareOutputs(toAddress, entry.Amount, []string{notary, params.From, entry.To}) + if err != nil { + return nil, nil, err + } + unlockedOutputs.coins = append(unlockedOutputs.coins, outputs.coins...) + unlockedOutputs.states = append(unlockedOutputs.states, outputs.states...) + } + + lockedOutputs := &preparedLockedOutputs{} + if remainder.Cmp(big.NewInt(0)) == 1 { + var err error + lockedOutputs, err = h.noto.prepareLockedOutputs(params.LockID, from, (*tktypes.HexUint256)(remainder), []string{notary, params.From}) + if err != nil { + return nil, nil, err + } + } + + return unlockedOutputs, lockedOutputs, nil +} + +func (h *unlockCommon) endorse( + ctx context.Context, + tx *types.ParsedTransaction, + params *types.UnlockParams, + req *prototk.EndorseTransactionRequest, + inputs, outputs *parsedCoins, +) (*prototk.EndorseTransactionResponse, error) { + if err := h.checkAllowed(ctx, tx, params.From); err != nil { + return nil, err + } + + // Validate the amounts, and lock creator's ownership of all locked inputs/outputs + if err := h.noto.validateUnlockAmounts(ctx, inputs, outputs); err != nil { + return nil, err + } + if err := h.noto.validateLockOwners(ctx, params.From, req.ResolvedVerifiers, inputs.lockedCoins, inputs.lockedStates); err != nil { + return nil, err + } + if err := h.noto.validateLockOwners(ctx, params.From, req.ResolvedVerifiers, outputs.lockedCoins, outputs.lockedStates); err != nil { + return nil, err + } + + // Notary checks the signatures from the sender, then submits the transaction + encodedUnlock, err := h.noto.encodeUnlock(ctx, tx.ContractAddress, inputs.lockedCoins, outputs.lockedCoins, outputs.coins) + if err != nil { + return nil, err + } + if err := h.noto.validateSignature(ctx, "sender", req.Signatures, encodedUnlock); err != nil { + return nil, err + } + return &prototk.EndorseTransactionResponse{ + EndorsementResult: prototk.EndorseTransactionResponse_ENDORSER_SUBMIT, + }, nil +} + +func (h *unlockHandler) ValidateParams(ctx context.Context, config *types.NotoParsedConfig, params string) (interface{}, error) { + var unlockParams types.UnlockParams + err := json.Unmarshal([]byte(params), &unlockParams) + if err == nil { + err = h.validateParams(ctx, &unlockParams) + } + return &unlockParams, err +} + +func (h *unlockHandler) Init(ctx context.Context, tx *types.ParsedTransaction, req *prototk.InitTransactionRequest) (*prototk.InitTransactionResponse, error) { + params := tx.Params.(*types.UnlockParams) + return h.init(ctx, tx, params) +} + +func (h *unlockHandler) Assemble(ctx context.Context, tx *types.ParsedTransaction, req *prototk.AssembleTransactionRequest) (*prototk.AssembleTransactionResponse, error) { + params := tx.Params.(*types.UnlockParams) + notary := tx.DomainConfig.NotaryLookup + + res, states, err := h.assembleStates(ctx, tx, params, req) + if err != nil || res.AssemblyResult != prototk.AssembleTransactionResponse_OK { + return res, err + } + + assembledTransaction := &prototk.AssembledTransaction{} + assembledTransaction.InputStates = states.lockedInputs.states + assembledTransaction.OutputStates = states.outputs.states + assembledTransaction.OutputStates = append(assembledTransaction.OutputStates, states.lockedOutputs.states...) + assembledTransaction.InfoStates = states.info + + encodedUnlock, err := h.noto.encodeUnlock(ctx, tx.ContractAddress, states.lockedInputs.coins, states.lockedOutputs.coins, states.outputs.coins) + if err != nil { + return nil, err + } + + return &prototk.AssembleTransactionResponse{ + AssemblyResult: prototk.AssembleTransactionResponse_OK, + AssembledTransaction: assembledTransaction, + AttestationPlan: []*prototk.AttestationRequest{ + // Sender confirms the initial request with a signature + { + Name: "sender", + AttestationType: prototk.AttestationType_SIGN, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Payload: encodedUnlock, + PayloadType: signpayloads.OPAQUE_TO_RSV, + Parties: []string{req.Transaction.From}, + }, + // Notary will endorse the assembled transaction (by submitting to the ledger) + { + Name: "notary", + AttestationType: prototk.AttestationType_ENDORSE, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + Parties: []string{notary}, + }, + }, + }, nil +} + +func (h *unlockHandler) Endorse(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest) (*prototk.EndorseTransactionResponse, error) { + params := tx.Params.(*types.UnlockParams) + inputs, err := h.noto.parseCoinList(ctx, "input", req.Inputs) + if err != nil { + return nil, err + } + outputs, err := h.noto.parseCoinList(ctx, "output", req.Outputs) + if err != nil { + return nil, err + } + return h.endorse(ctx, tx, params, req, inputs, outputs) +} + +func (h *unlockHandler) baseLedgerInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.UnlockParams) + lockedInputs := req.InputStates + outputs, lockedOutputs := h.noto.splitStates(req.OutputStates) + + // Include the signature from the sender + // This is not verified on the base ledger, but can be verified by anyone with the unmasked state data + unlockSignature := domain.FindAttestation("sender", req.AttestationResult) + if unlockSignature == nil { + return nil, i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") + } + + data, err := h.noto.encodeTransactionData(ctx, req.Transaction, req.InfoStates) + if err != nil { + return nil, err + } + params := &NotoUnlockParams{ + LockID: inParams.LockID, + LockedInputs: endorsableStateIDs(lockedInputs), + LockedOutputs: endorsableStateIDs(lockedOutputs), + Outputs: endorsableStateIDs(outputs), + Signature: unlockSignature.Payload, + Data: data, + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + return &TransactionWrapper{ + functionABI: h.noto.contractABI.Functions()["unlock"], + paramsJSON: paramsJSON, + }, nil +} + +func (h *unlockHandler) hookInvoke(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest, baseTransaction *TransactionWrapper) (*TransactionWrapper, error) { + inParams := tx.Params.(*types.UnlockParams) + + senderAddress, err := h.noto.findEthAddressVerifier(ctx, "sender", tx.Transaction.From, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + unlock := make([]*ResolvedUnlockRecipient, len(inParams.Recipients)) + for i, entry := range inParams.Recipients { + to, err := h.noto.findEthAddressVerifier(ctx, "to", entry.To, req.ResolvedVerifiers) + if err != nil { + return nil, err + } + unlock[i] = &ResolvedUnlockRecipient{To: to, Amount: entry.Amount} + } + + encodedCall, err := baseTransaction.encode(ctx) + if err != nil { + return nil, err + } + params := &UnlockHookParams{ + Sender: senderAddress, + LockID: inParams.LockID, + Recipients: unlock, + Data: inParams.Data, + Prepared: PreparedTransaction{ + ContractAddress: (*tktypes.EthAddress)(tx.ContractAddress), + EncodedCall: encodedCall, + }, + } + + transactionType, functionABI, paramsJSON, err := h.noto.wrapHookTransaction( + tx.DomainConfig, + h.noto.hooksABI.Functions()["onUnlock"], + params, + ) + if err != nil { + return nil, err + } + + return &TransactionWrapper{ + transactionType: mapPrepareTransactionType(transactionType), + functionABI: functionABI, + paramsJSON: paramsJSON, + contractAddress: tx.DomainConfig.Options.Hooks.PublicAddress, + }, nil +} + +func (h *unlockHandler) Prepare(ctx context.Context, tx *types.ParsedTransaction, req *prototk.PrepareTransactionRequest) (*prototk.PrepareTransactionResponse, error) { + baseTransaction, err := h.baseLedgerInvoke(ctx, tx, req) + if err != nil { + return nil, err + } + + if tx.DomainConfig.NotaryMode == types.NotaryModeHooks.Enum() { + hookTransaction, err := h.hookInvoke(ctx, tx, req, baseTransaction) + if err != nil { + return nil, err + } + return hookTransaction.prepare(nil) + } + + return baseTransaction.prepare(nil) +} diff --git a/domains/noto/internal/noto/handlers.go b/domains/noto/internal/noto/handlers.go index 3bafc0c4d..af489d807 100644 --- a/domains/noto/internal/noto/handlers.go +++ b/domains/noto/internal/noto/handlers.go @@ -42,94 +42,121 @@ func (n *Noto) GetHandler(method string) types.DomainHandler { return &burnHandler{noto: n} case "approveTransfer": return &approveHandler{noto: n} + case "lock": + return &lockHandler{noto: n} + case "unlock": + return &unlockHandler{ + unlockCommon: unlockCommon{noto: n}, + } + case "prepareUnlock": + return &prepareUnlockHandler{ + unlockCommon: unlockCommon{noto: n}, + } + case "delegateLock": + return &delegateLockHandler{noto: n} default: return nil } } // Check that a mint has no inputs, and an output matching the requested amount -func (n *Noto) validateMintAmounts(ctx context.Context, params *types.MintParams, coins *gatheredCoins) error { - if len(coins.inCoins) > 0 { - return i18n.NewError(ctx, msgs.MsgInvalidInputs, "mint", coins.inCoins) +func (n *Noto) validateMintAmounts(ctx context.Context, params *types.MintParams, inputs, outputs *parsedCoins) error { + if len(inputs.coins) > 0 { + return i18n.NewError(ctx, msgs.MsgInvalidInputs, "mint", inputs.coins) } - if coins.outTotal.Cmp(params.Amount.Int()) != 0 { - return i18n.NewError(ctx, msgs.MsgInvalidAmount, "mint", params.Amount.Int().Text(10), coins.outTotal.Text(10)) + if outputs.total.Cmp(params.Amount.Int()) != 0 { + return i18n.NewError(ctx, msgs.MsgInvalidAmount, "mint", params.Amount.Int().Text(10), outputs.total.Text(10)) } return nil } // Check that a transfer has at least one input and output, and they net out to zero -func (n *Noto) validateTransferAmounts(ctx context.Context, coins *gatheredCoins) error { - if len(coins.inCoins) == 0 { - return i18n.NewError(ctx, msgs.MsgInvalidInputs, "transfer", coins.inCoins) +func (n *Noto) validateTransferAmounts(ctx context.Context, inputs, outputs *parsedCoins) error { + if len(inputs.coins) == 0 { + return i18n.NewError(ctx, msgs.MsgInvalidInputs, "transfer", inputs.coins) } - if coins.inTotal.Cmp(coins.outTotal) != 0 { - return i18n.NewError(ctx, msgs.MsgInvalidAmount, "transfer", coins.inTotal, coins.outTotal) + if inputs.total.Cmp(outputs.total) != 0 { + return i18n.NewError(ctx, msgs.MsgInvalidAmount, "transfer", inputs.total, outputs.total) } return nil } // Check that a burn has at least one input, and a net output matching the requested amount -func (n *Noto) validateBurnAmounts(ctx context.Context, params *types.BurnParams, coins *gatheredCoins) error { - if len(coins.inCoins) == 0 { - return i18n.NewError(ctx, msgs.MsgInvalidInputs, "burn", coins.inCoins) +func (n *Noto) validateBurnAmounts(ctx context.Context, params *types.BurnParams, inputs, outputs *parsedCoins) error { + if len(inputs.coins) == 0 { + return i18n.NewError(ctx, msgs.MsgInvalidInputs, "burn", inputs.coins) } - amount := big.NewInt(0).Sub(coins.inTotal, coins.outTotal) + amount := big.NewInt(0).Sub(inputs.total, outputs.total) if amount.Cmp(params.Amount.Int()) != 0 { return i18n.NewError(ctx, msgs.MsgInvalidAmount, "burn", params.Amount.Int().Text(10), amount.Text(10)) } return nil } -// Check that the sender of a transfer provided a signature on the input transaction details -func (n *Noto) validateTransferSignature(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest, coins *gatheredCoins) error { - signature := domain.FindAttestation("sender", req.Signatures) - if signature == nil { - return i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") +// Check that a lock produces locked coins matching the difference between the inputs and outputs +func (n *Noto) validateLockAmounts(ctx context.Context, inputs, outputs *parsedCoins) error { + if len(inputs.coins) == 0 { + return i18n.NewError(ctx, msgs.MsgInvalidInputs, "lock", inputs.coins) } - if signature.Verifier.Lookup != tx.Transaction.From { - return i18n.NewError(ctx, msgs.MsgAttestationUnexpected, "sender", tx.Transaction.From, signature.Verifier.Lookup) + amount := big.NewInt(0).Sub(inputs.total, outputs.total) + if amount.Cmp(outputs.lockedTotal) != 0 { + return i18n.NewError(ctx, msgs.MsgInvalidAmount, "lock", outputs.lockedTotal.Text(10), amount.Text(10)) } - encodedTransfer, err := n.encodeTransferUnmasked(ctx, tx.ContractAddress, coins.inCoins, coins.outCoins) - if err != nil { - return err + return nil +} + +// Check that an unlock produces unlocked coins matching the difference between the locked inputs and outputs +func (n *Noto) validateUnlockAmounts(ctx context.Context, inputs, outputs *parsedCoins) error { + if len(inputs.lockedCoins) == 0 { + return i18n.NewError(ctx, msgs.MsgInvalidInputs, "unlock", inputs.lockedCoins) } - recoveredSignature, err := n.recoverSignature(ctx, encodedTransfer, signature.Payload) - if err != nil { - return err - } - if recoveredSignature.String() != signature.Verifier.Verifier { - return i18n.NewError(ctx, msgs.MsgSignatureDoesNotMatch, "sender", signature.Verifier.Verifier, recoveredSignature.String()) + amount := big.NewInt(0).Sub(inputs.lockedTotal, outputs.lockedTotal) + if amount.Cmp(outputs.total) != 0 { + return i18n.NewError(ctx, msgs.MsgInvalidAmount, "unlock", outputs.total.Text(10), amount.Text(10)) } return nil } -// Check that the sender of an approval provided a signature on the input transaction details -func (n *Noto) validateApprovalSignature(ctx context.Context, req *prototk.EndorseTransactionRequest, transferHash []byte) error { - signature := domain.FindAttestation("sender", req.Signatures) +// Check that the sender of a transaction provided a signature on the input details +func (n *Noto) validateSignature(ctx context.Context, name string, attestations []*prototk.AttestationResult, encodedMessage []byte) error { + signature := domain.FindAttestation(name, attestations) if signature == nil { - return i18n.NewError(ctx, msgs.MsgAttestationNotFound, "sender") + return i18n.NewError(ctx, msgs.MsgAttestationNotFound, name) } - recoveredSignature, err := n.recoverSignature(ctx, transferHash, signature.Payload) + recoveredSignature, err := n.recoverSignature(ctx, encodedMessage, signature.Payload) if err != nil { return err } if recoveredSignature.String() != signature.Verifier.Verifier { - return i18n.NewError(ctx, msgs.MsgSignatureDoesNotMatch, "sender", signature.Verifier.Verifier, recoveredSignature.String()) + return i18n.NewError(ctx, msgs.MsgSignatureDoesNotMatch, name, signature.Verifier.Verifier, recoveredSignature.String()) } return nil } -// Check that all input coins are owned by the transaction sender -func (n *Noto) validateOwners(ctx context.Context, tx *types.ParsedTransaction, req *prototk.EndorseTransactionRequest, coins *gatheredCoins) error { - fromAddress, err := n.findEthAddressVerifier(ctx, "from", tx.Transaction.From, req.ResolvedVerifiers) +// Check that all coins are owned by the transaction sender +func (n *Noto) validateOwners(ctx context.Context, owner string, req *prototk.EndorseTransactionRequest, coins []*types.NotoCoin, states []*prototk.StateRef) error { + fromAddress, err := n.findEthAddressVerifier(ctx, "from", owner, req.ResolvedVerifiers) if err != nil { return err } - for i, coin := range coins.inCoins { + for i, coin := range coins { + if !coin.Owner.Equals(fromAddress) { + return i18n.NewError(ctx, msgs.MsgStateWrongOwner, states[i].Id, owner) + } + } + return nil +} + +// Check that all locked coins are owned by the transaction sender +func (n *Noto) validateLockOwners(ctx context.Context, owner string, verifiers []*prototk.ResolvedVerifier, coins []*types.NotoLockedCoin, states []*prototk.StateRef) error { + fromAddress, err := n.findEthAddressVerifier(ctx, "from", owner, verifiers) + if err != nil { + return err + } + for i, coin := range coins { if !coin.Owner.Equals(fromAddress) { - return i18n.NewError(ctx, msgs.MsgStateWrongOwner, coins.inStates[i].Id, tx.Transaction.From) + return i18n.NewError(ctx, msgs.MsgStateWrongOwner, states[i].Id, owner) } } return nil diff --git a/domains/noto/internal/noto/hooks.go b/domains/noto/internal/noto/hooks.go index b152c2976..99c54443e 100644 --- a/domains/noto/internal/noto/hooks.go +++ b/domains/noto/internal/noto/hooks.go @@ -25,6 +25,7 @@ type MintHookParams struct { Sender *tktypes.EthAddress `json:"sender"` To *tktypes.EthAddress `json:"to"` Amount *tktypes.HexUint256 `json:"amount"` + Data tktypes.HexBytes `json:"data"` Prepared PreparedTransaction `json:"prepared"` } @@ -33,6 +34,7 @@ type TransferHookParams struct { From *tktypes.EthAddress `json:"from"` To *tktypes.EthAddress `json:"to"` Amount *tktypes.HexUint256 `json:"amount"` + Data tktypes.HexBytes `json:"data"` Prepared PreparedTransaction `json:"prepared"` } @@ -40,6 +42,7 @@ type BurnHookParams struct { Sender *tktypes.EthAddress `json:"sender"` From *tktypes.EthAddress `json:"from"` Amount *tktypes.HexUint256 `json:"amount"` + Data tktypes.HexBytes `json:"data"` Prepared PreparedTransaction `json:"prepared"` } @@ -47,14 +50,52 @@ type ApproveTransferHookParams struct { Sender *tktypes.EthAddress `json:"sender"` From *tktypes.EthAddress `json:"from"` Delegate *tktypes.EthAddress `json:"delegate"` + Data tktypes.HexBytes `json:"data"` Prepared PreparedTransaction `json:"prepared"` } +type LockHookParams struct { + Sender *tktypes.EthAddress `json:"sender"` + LockID tktypes.Bytes32 `json:"lockId"` + From *tktypes.EthAddress `json:"from"` + Amount *tktypes.HexUint256 `json:"amount"` + Data tktypes.HexBytes `json:"data"` + Prepared PreparedTransaction `json:"prepared"` +} + +type UnlockHookParams struct { + Sender *tktypes.EthAddress `json:"sender"` + LockID tktypes.Bytes32 `json:"lockId"` + Recipients []*ResolvedUnlockRecipient `json:"recipients"` + Data tktypes.HexBytes `json:"data"` + Prepared PreparedTransaction `json:"prepared"` +} + +type ApproveUnlockHookParams struct { + Sender *tktypes.EthAddress `json:"sender"` + LockID tktypes.Bytes32 `json:"lockId"` + Delegate *tktypes.EthAddress `json:"delegate"` + Data tktypes.HexBytes `json:"data"` + Prepared PreparedTransaction `json:"prepared"` +} + +type DelegateUnlockHookParams struct { + Sender *tktypes.EthAddress `json:"sender"` + LockID tktypes.Bytes32 `json:"lockId"` + Recipients []*ResolvedUnlockRecipient `json:"recipients"` + Data tktypes.HexBytes `json:"data"` +} + type PreparedTransaction struct { ContractAddress *tktypes.EthAddress `json:"contractAddress"` EncodedCall tktypes.HexBytes `json:"encodedCall"` } +type ResolvedUnlockRecipient struct { + To *tktypes.EthAddress `json:"to"` + Amount *tktypes.HexUint256 `json:"amount"` +} + func penteInvokeABI(name string, inputs abi.ParameterArray) *abi.Entry { return &abi.Entry{ Name: name, diff --git a/domains/noto/internal/noto/noto.go b/domains/noto/internal/noto/noto.go index 74a71b860..265f9d7e1 100644 --- a/domains/noto/internal/noto/noto.go +++ b/domains/noto/internal/noto/noto.go @@ -30,6 +30,8 @@ import ( "github.com/kaleido-io/paladin/domains/noto/internal/msgs" "github.com/kaleido-io/paladin/domains/noto/pkg/types" "github.com/kaleido-io/paladin/toolkit/pkg/algorithms" + "github.com/kaleido-io/paladin/toolkit/pkg/log" + "github.com/kaleido-io/paladin/toolkit/pkg/pldapi" "github.com/kaleido-io/paladin/toolkit/pkg/plugintk" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/solutils" @@ -46,6 +48,24 @@ var notoInterfaceJSON []byte //go:embed abis/INotoHooks.json var notoHooksJSON []byte +var ( + NotoTransfer = "NotoTransfer" + NotoApproved = "NotoApproved" + NotoLock = "NotoLock" + NotoUnlock = "NotoUnlock" + NotoUnlockPrepared = "NotoUnlockPrepared" + NotoLockDelegated = "NotoLockDelegated" +) + +var allEvents = []string{ + NotoTransfer, + NotoApproved, + NotoLock, + NotoUnlock, + NotoUnlockPrepared, + NotoLockDelegated, +} + func NewNoto(callbacks plugintk.DomainCallbacks) plugintk.DomainAPI { return &Noto{Callbacks: callbacks} } @@ -53,15 +73,17 @@ func NewNoto(callbacks plugintk.DomainCallbacks) plugintk.DomainAPI { type Noto struct { Callbacks plugintk.DomainCallbacks - name string - config types.DomainConfig - chainID int64 - coinSchema *prototk.StateSchema - dataSchema *prototk.StateSchema - factoryABI abi.ABI - contractABI abi.ABI - transferSignature string - approvedSignature string + name string + config types.DomainConfig + chainID int64 + coinSchema *prototk.StateSchema + lockedCoinSchema *prototk.StateSchema + dataSchema *prototk.StateSchema + lockInfoSchema *prototk.StateSchema + factoryABI abi.ABI + contractABI abi.ABI + hooksABI abi.ABI + eventSignatures map[string]string } type NotoDeployParams struct { @@ -84,9 +106,49 @@ type NotoTransferParams struct { Data tktypes.HexBytes `json:"data"` } +type NotoBurnParams struct { + Inputs []string `json:"inputs"` + Outputs []string `json:"outputs"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + type NotoApproveTransferParams struct { Delegate *tktypes.EthAddress `json:"delegate"` - TXHash tktypes.HexBytes `json:"txhash"` + TXHash tktypes.Bytes32 `json:"txhash"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoLockParams struct { + LockID tktypes.Bytes32 `json:"lockId"` + Inputs []string `json:"inputs"` + Outputs []string `json:"outputs"` + LockedOutputs []string `json:"lockedOutputs"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoUnlockParams struct { + LockID tktypes.Bytes32 `json:"lockId"` + LockedInputs []string `json:"lockedInputs"` + LockedOutputs []string `json:"lockedOutputs"` + Outputs []string `json:"outputs"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoPrepareUnlockParams struct { + LockID tktypes.Bytes32 `json:"lockId"` + LockedInputs []string `json:"lockedInputs"` + UnlockHash tktypes.Bytes32 `json:"unlockHash"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoDelegateLockParams struct { + LockID tktypes.Bytes32 `json:"lockId"` + Delegate *tktypes.EthAddress `json:"delegate"` Signature tktypes.HexBytes `json:"signature"` Data tktypes.HexBytes `json:"data"` } @@ -105,13 +167,47 @@ type NotoApproved_Event struct { Data tktypes.HexBytes `json:"data"` } -type gatheredCoins struct { - inCoins []*types.NotoCoin - inStates []*prototk.StateRef - inTotal *big.Int - outCoins []*types.NotoCoin - outStates []*prototk.StateRef - outTotal *big.Int +type NotoLock_Event struct { + LockID tktypes.Bytes32 `json:"lockId"` + Inputs []tktypes.Bytes32 `json:"inputs"` + Outputs []tktypes.Bytes32 `json:"outputs"` + LockedOutputs []tktypes.Bytes32 `json:"lockedOutputs"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoUnlock_Event struct { + LockID tktypes.Bytes32 `json:"lockId"` + Sender *tktypes.EthAddress `json:"sender"` + LockedInputs []tktypes.Bytes32 `json:"lockedInputs"` + LockedOutputs []tktypes.Bytes32 `json:"lockedOutputs"` + Outputs []tktypes.Bytes32 `json:"outputs"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoUnlockPrepared_Event struct { + LockID tktypes.Bytes32 `json:"lockId"` + LockedInputs []tktypes.Bytes32 `json:"lockedInputs"` + UnlockHash tktypes.Bytes32 `json:"unlockHash"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type NotoLockDelegated_Event struct { + LockID tktypes.Bytes32 `json:"lockId"` + Delegate *tktypes.EthAddress `json:"delegate"` + Signature tktypes.HexBytes `json:"signature"` + Data tktypes.HexBytes `json:"data"` +} + +type parsedCoins struct { + coins []*types.NotoCoin + states []*prototk.StateRef + total *big.Int + lockedCoins []*types.NotoLockedCoin + lockedStates []*prototk.StateRef + lockedTotal *big.Int } func getEventSignature(ctx context.Context, abi abi.ABI, eventName string) (string, error) { @@ -130,6 +226,14 @@ func (n *Noto) CoinSchemaID() string { return n.coinSchema.Id } +func (n *Noto) LockedCoinSchemaID() string { + return n.lockedCoinSchema.Id +} + +func (n *Noto) LockInfoSchemaID() string { + return n.lockInfoSchema.Id +} + func (n *Noto) ConfigureDomain(ctx context.Context, req *prototk.ConfigureDomainRequest) (*prototk.ConfigureDomainResponse, error) { err := json.Unmarshal([]byte(req.ConfigJson), &n.config) if err != nil { @@ -138,22 +242,32 @@ func (n *Noto) ConfigureDomain(ctx context.Context, req *prototk.ConfigureDomain factory := solutils.MustLoadBuild(notoFactoryJSON) contract := solutils.MustLoadBuild(notoInterfaceJSON) + hooks := solutils.MustLoadBuild(notoHooksJSON) n.name = req.Name n.chainID = req.ChainId n.factoryABI = factory.ABI n.contractABI = contract.ABI + n.hooksABI = hooks.ABI - n.transferSignature, err = getEventSignature(ctx, contract.ABI, "NotoTransfer") + n.eventSignatures = make(map[string]string, len(allEvents)) + for _, eventName := range allEvents { + signature, err := getEventSignature(ctx, contract.ABI, eventName) + if err != nil { + return nil, err + } + n.eventSignatures[eventName] = signature + } + + coinSchemaJSON, err := json.Marshal(types.NotoCoinABI) if err != nil { return nil, err } - n.approvedSignature, err = getEventSignature(ctx, contract.ABI, "NotoApproved") + lockSchemaJSON, err := json.Marshal(types.NotoLockInfoABI) if err != nil { return nil, err } - - coinSchemaJSON, err := json.Marshal(types.NotoCoinABI) + lockedCoinSchemaJSON, err := json.Marshal(types.NotoLockedCoinABI) if err != nil { return nil, err } @@ -175,15 +289,22 @@ func (n *Noto) ConfigureDomain(ctx context.Context, req *prototk.ConfigureDomain return &prototk.ConfigureDomainResponse{ DomainConfig: &prototk.DomainConfig{ - AbiStateSchemasJson: []string{string(coinSchemaJSON), string(infoSchemaJSON)}, - AbiEventsJson: string(eventsJSON), + AbiStateSchemasJson: []string{ + string(coinSchemaJSON), + string(lockedCoinSchemaJSON), + string(infoSchemaJSON), + string(lockSchemaJSON), + }, + AbiEventsJson: string(eventsJSON), }, }, nil } func (n *Noto) InitDomain(ctx context.Context, req *prototk.InitDomainRequest) (*prototk.InitDomainResponse, error) { n.coinSchema = req.AbiStateSchemas[0] - n.dataSchema = req.AbiStateSchemas[1] + n.lockedCoinSchema = req.AbiStateSchemas[1] + n.dataSchema = req.AbiStateSchemas[2] + n.lockInfoSchema = req.AbiStateSchemas[3] return &prototk.InitDomainResponse{}, nil } @@ -192,6 +313,29 @@ func (n *Noto) InitDeploy(ctx context.Context, req *prototk.InitDeployRequest) ( if err != nil { return nil, err } + + switch params.NotaryMode { + case types.NotaryModeBasic: + // no required params + case types.NotaryModeHooks: + if params.Options.Hooks == nil { + return nil, i18n.NewError(ctx, msgs.MsgParameterRequired, "options.hooks") + } + if params.Options.Hooks.PublicAddress == nil { + return nil, i18n.NewError(ctx, msgs.MsgParameterRequired, "options.hooks.notaryAddress") + } + if !params.Options.Hooks.DevUsePublicHooks { + if params.Options.Hooks.PrivateAddress == nil { + return nil, i18n.NewError(ctx, msgs.MsgParameterRequired, "options.hooks.privateAddress") + } + if params.Options.Hooks.PrivateGroup == nil { + return nil, i18n.NewError(ctx, msgs.MsgParameterRequired, "options.hooks.privateGroup") + } + } + default: + return nil, i18n.NewError(ctx, msgs.MsgParameterRequired, "notaryMode") + } + return &prototk.InitDeployResponse{ RequiredVerifiers: []*prototk.ResolveVerifierRequest{ { @@ -212,25 +356,41 @@ func (n *Noto) PrepareDeploy(ctx context.Context, req *prototk.PrepareDeployRequ if err != nil { return nil, err } + localNodeName, _ := n.Callbacks.LocalNodeName(ctx, &prototk.LocalNodeNameRequest{}) + notaryQualified, err := tktypes.PrivateIdentityLocator(params.Notary).FullyQualified(ctx, localNodeName.Name) + if err != nil { + return nil, err + } deployData := &types.NotoConfigData_V0{ - NotaryLookup: params.Notary, - NotaryType: types.NotaryTypeSigner, - RestrictMinting: true, - AllowBurning: true, - } - if params.RestrictMinting != nil { - deployData.RestrictMinting = *params.RestrictMinting - } - if params.AllowBurning != nil { - deployData.AllowBurning = *params.AllowBurning + NotaryLookup: notaryQualified.String(), } - - if params.Hooks != nil && !params.Hooks.PublicAddress.IsZero() { - notaryAddress = params.Hooks.PublicAddress - deployData.NotaryType = types.NotaryTypePente - deployData.PrivateAddress = params.Hooks.PrivateAddress - deployData.PrivateGroup = params.Hooks.PrivateGroup + switch params.NotaryMode { + case types.NotaryModeBasic: + deployData.NotaryMode = types.NotaryModeIntBasic + deployData.RestrictMint = true + deployData.AllowBurn = true + deployData.AllowLock = true + deployData.RestrictUnlock = true + if params.Options.Basic != nil { + if params.Options.Basic.RestrictMint != nil { + deployData.RestrictMint = *params.Options.Basic.RestrictMint + } + if params.Options.Basic.AllowBurn != nil { + deployData.AllowBurn = *params.Options.Basic.AllowBurn + } + if params.Options.Basic.AllowLock != nil { + deployData.AllowLock = *params.Options.Basic.AllowLock + } + if params.Options.Basic.RestrictUnlock != nil { + deployData.RestrictUnlock = *params.Options.Basic.RestrictUnlock + } + } + case types.NotaryModeHooks: + deployData.NotaryMode = types.NotaryModeIntHooks + deployData.PrivateAddress = params.Options.Hooks.PrivateAddress + deployData.PrivateGroup = params.Options.Hooks.PrivateGroup + notaryAddress = params.Options.Hooks.PublicAddress } deployDataJSON, err := json.Marshal(deployData) @@ -269,35 +429,53 @@ func (n *Noto) PrepareDeploy(ctx context.Context, req *prototk.PrepareDeployRequ func (n *Noto) InitContract(ctx context.Context, req *prototk.InitContractRequest) (*prototk.InitContractResponse, error) { var notoContractConfigJSON []byte - var staticCoordinator string - domainConfig, err := n.decodeConfig(ctx, req.ContractConfig) - if err == nil { - parsedConfig := &types.NotoParsedConfig{ - NotaryType: domainConfig.DecodedData.NotaryType, - NotaryAddress: domainConfig.NotaryAddress, - Variant: domainConfig.Variant, - NotaryLookup: domainConfig.DecodedData.NotaryLookup, - PrivateAddress: domainConfig.DecodedData.PrivateAddress, - PrivateGroup: domainConfig.DecodedData.PrivateGroup, - RestrictMinting: domainConfig.DecodedData.RestrictMinting, - AllowBurning: domainConfig.DecodedData.AllowBurning, - } - notoContractConfigJSON, err = json.Marshal(parsedConfig) - } - if err == nil { - staticCoordinator = domainConfig.DecodedData.NotaryLookup - } + + domainConfig, decodedData, err := n.decodeConfig(ctx, req.ContractConfig) if err != nil { // This on-chain contract has invalid configuration - not an error in our process return &prototk.InitContractResponse{Valid: false}, nil } + localNodeName, _ := n.Callbacks.LocalNodeName(ctx, &prototk.LocalNodeNameRequest{}) + _, notaryNodeName, err := tktypes.PrivateIdentityLocator(decodedData.NotaryLookup).Validate(ctx, localNodeName.Name, true) + if err != nil { + return nil, err + } + + parsedConfig := &types.NotoParsedConfig{ + NotaryMode: types.NotaryModeBasic.Enum(), + Variant: domainConfig.Variant, + NotaryLookup: decodedData.NotaryLookup, + IsNotary: notaryNodeName == localNodeName.Name, + } + if decodedData.NotaryMode == types.NotaryModeIntHooks { + parsedConfig.NotaryMode = types.NotaryModeHooks.Enum() + parsedConfig.Options.Hooks = &types.NotoHooksOptions{ + PublicAddress: &domainConfig.NotaryAddress, + PrivateAddress: decodedData.PrivateAddress, + PrivateGroup: decodedData.PrivateGroup, + DevUsePublicHooks: decodedData.PrivateAddress == nil, + } + } else { + parsedConfig.Options.Basic = &types.NotoBasicOptions{ + RestrictMint: &decodedData.RestrictMint, + AllowBurn: &decodedData.AllowBurn, + AllowLock: &decodedData.AllowLock, + RestrictUnlock: &decodedData.RestrictUnlock, + } + } + + notoContractConfigJSON, err = json.Marshal(parsedConfig) + if err != nil { + return nil, err + } + return &prototk.InitContractResponse{ Valid: true, ContractConfig: &prototk.ContractConfig{ ContractConfigJson: string(notoContractConfigJSON), CoordinatorSelection: prototk.ContractConfig_COORDINATOR_STATIC, - StaticCoordinator: &staticCoordinator, + StaticCoordinator: &decodedData.NotaryLookup, SubmitterSelection: prototk.ContractConfig_SUBMITTER_COORDINATOR, }, }, nil @@ -335,26 +513,27 @@ func (n *Noto) PrepareTransaction(ctx context.Context, req *prototk.PrepareTrans return handler.Prepare(ctx, tx, req) } -func (n *Noto) decodeConfig(ctx context.Context, domainConfig []byte) (*types.NotoConfig_V0, error) { +func (n *Noto) decodeConfig(ctx context.Context, domainConfig []byte) (*types.NotoConfig_V0, *types.NotoConfigData_V0, error) { configSelector := ethtypes.HexBytes0xPrefix(domainConfig[0:4]) if configSelector.String() != types.NotoConfigID_V0.String() { - return nil, i18n.NewError(ctx, msgs.MsgUnexpectedConfigType, configSelector) + return nil, nil, i18n.NewError(ctx, msgs.MsgUnexpectedConfigType, configSelector) } configValues, err := types.NotoConfigABI_V0.DecodeABIDataCtx(ctx, domainConfig[4:], 0) if err != nil { - return nil, err + return nil, nil, err } configJSON, err := tktypes.StandardABISerializer().SerializeJSON(configValues) if err != nil { - return nil, err + return nil, nil, err } var config types.NotoConfig_V0 err = json.Unmarshal(configJSON, &config) if err != nil { - return nil, err + return nil, nil, err } - err = json.Unmarshal(config.Data, &config.DecodedData) - return &config, err + var decodedData types.NotoConfigData_V0 + err = json.Unmarshal(config.Data, &decodedData) + return &config, &decodedData, err } func (n *Noto) validateDeploy(tx *prototk.DeployTransactionSpecification) (*types.ConstructorParams, error) { @@ -408,6 +587,26 @@ func (n *Noto) validateTransaction(ctx context.Context, tx *prototk.TransactionS }, handler, nil } +func (n *Noto) ethAddressVerifiers(lookups ...string) []*prototk.ResolveVerifierRequest { + verifierMap := make(map[string]bool, len(lookups)) + verifierList := make([]string, 0, len(lookups)) + for _, lookup := range lookups { + if _, ok := verifierMap[lookup]; !ok { + verifierMap[lookup] = true + verifierList = append(verifierList, lookup) + } + } + request := make([]*prototk.ResolveVerifierRequest, len(verifierList)) + for i, lookup := range verifierList { + request[i] = &prototk.ResolveVerifierRequest{ + Lookup: lookup, + Algorithm: algorithms.ECDSA_SECP256K1, + VerifierType: verifiers.ETH_ADDRESS, + } + } + return request +} + func (n *Noto) recoverSignature(ctx context.Context, payload ethtypes.HexBytes0xPrefix, signature []byte) (*ethtypes.Address0xHex, error) { sig, err := secp256k1.DecodeCompactRSV(ctx, signature) if err != nil { @@ -416,49 +615,50 @@ func (n *Noto) recoverSignature(ctx context.Context, payload ethtypes.HexBytes0x return sig.RecoverDirect(payload, n.chainID) } -func (n *Noto) parseCoinList(ctx context.Context, label string, states []*prototk.EndorsableState) ([]*types.NotoCoin, []*prototk.StateRef, *big.Int, error) { - var err error +func (n *Noto) parseCoinList(ctx context.Context, label string, states []*prototk.EndorsableState) (*parsedCoins, error) { statesUsed := make(map[string]bool) - coins := make([]*types.NotoCoin, len(states)) - refs := make([]*prototk.StateRef, len(states)) - total := big.NewInt(0) + result := &parsedCoins{ + total: new(big.Int), + lockedTotal: new(big.Int), + } for i, state := range states { - if state.SchemaId != n.coinSchema.Id { - return nil, nil, nil, i18n.NewError(ctx, msgs.MsgUnknownSchema, state.SchemaId) - } if statesUsed[state.Id] { - return nil, nil, nil, i18n.NewError(ctx, msgs.MsgDuplicateStateInList, label, i, state.Id) + return nil, i18n.NewError(ctx, msgs.MsgDuplicateStateInList, label, i, state.Id) } statesUsed[state.Id] = true - if coins[i], err = n.unmarshalCoin(state.StateDataJson); err != nil { - return nil, nil, nil, i18n.NewError(ctx, msgs.MsgInvalidListInput, label, i, state.Id, err) - } - refs[i] = &prototk.StateRef{ - SchemaId: state.SchemaId, - Id: state.Id, - } - total = total.Add(total, coins[i].Amount.Int()) - } - return coins, refs, total, nil -} -func (n *Noto) gatherCoins(ctx context.Context, inputs, outputs []*prototk.EndorsableState) (*gatheredCoins, error) { - inCoins, inStates, inTotal, err := n.parseCoinList(ctx, "input", inputs) - if err != nil { - return nil, err - } - outCoins, outStates, outTotal, err := n.parseCoinList(ctx, "output", outputs) - if err != nil { - return nil, err + switch state.SchemaId { + case n.coinSchema.Id: + coin, err := n.unmarshalCoin(state.StateDataJson) + if err != nil { + return nil, i18n.NewError(ctx, msgs.MsgInvalidListInput, label, i, state.Id, err) + } + result.coins = append(result.coins, coin) + result.total = result.total.Add(result.total, coin.Amount.Int()) + result.states = append(result.states, &prototk.StateRef{ + SchemaId: state.SchemaId, + Id: state.Id, + }) + break + + case n.lockedCoinSchema.Id: + coin, err := n.unmarshalLockedCoin(state.StateDataJson) + if err != nil { + return nil, i18n.NewError(ctx, msgs.MsgInvalidListInput, label, i, state.Id, err) + } + result.lockedCoins = append(result.lockedCoins, coin) + result.lockedTotal = result.lockedTotal.Add(result.lockedTotal, coin.Amount.Int()) + result.lockedStates = append(result.lockedStates, &prototk.StateRef{ + SchemaId: state.SchemaId, + Id: state.Id, + }) + break + + default: + return nil, i18n.NewError(ctx, msgs.MsgUnexpectedSchema, state.SchemaId) + } } - return &gatheredCoins{ - inCoins: inCoins, - inStates: inStates, - inTotal: inTotal, - outCoins: outCoins, - outStates: outStates, - outTotal: outTotal, - }, nil + return result, nil } func (n *Noto) encodeTransactionData(ctx context.Context, transaction *prototk.TransactionSpecification, infoStates []*prototk.EndorsableState) (tktypes.HexBytes, error) { @@ -495,24 +695,28 @@ func (n *Noto) encodeTransactionData(ctx context.Context, transaction *prototk.T } func (n *Noto) decodeTransactionData(ctx context.Context, data tktypes.HexBytes) (*types.NotoTransactionData_V0, error) { - if len(data) < 4 { - return nil, nil - } - dataPrefix := data[0:4] - if dataPrefix.String() != types.NotoTransactionDataID_V0.String() { - return nil, nil + var dataValues types.NotoTransactionData_V0 + if len(data) >= 4 { + dataPrefix := data[0:4] + if dataPrefix.String() == types.NotoTransactionDataID_V0.String() { + dataDecoded, err := types.NotoTransactionDataABI_V0.DecodeABIDataCtx(ctx, data, 4) + if err == nil { + var dataJSON []byte + dataJSON, err = dataDecoded.JSON() + if err == nil { + err = json.Unmarshal(dataJSON, &dataValues) + } + } + if err != nil { + return nil, err + } + } } - dataDecoded, err := types.NotoTransactionDataABI_V0.DecodeABIDataCtx(ctx, data, 4) - if err != nil { - return nil, err + if dataValues.TransactionID.IsZero() { + // If no transaction ID could be decoded, assign a random one + dataValues.TransactionID = tktypes.RandBytes32() } - dataJSON, err := dataDecoded.JSON() - if err != nil { - return nil, err - } - var dataValues types.NotoTransactionData_V0 - err = json.Unmarshal(dataJSON, &dataValues) - return &dataValues, err + return &dataValues, nil } func (n *Noto) parseStatesFromEvent(txID tktypes.Bytes32, states []tktypes.Bytes32) []*prototk.StateUpdate { @@ -526,54 +730,228 @@ func (n *Noto) parseStatesFromEvent(txID tktypes.Bytes32, states []tktypes.Bytes return refs } +func (n *Noto) recordTransactionInfo(ev *prototk.OnChainEvent, txData *types.NotoTransactionData_V0, res *prototk.HandleEventBatchResponse) { + res.TransactionsComplete = append(res.TransactionsComplete, &prototk.CompletedTransaction{ + TransactionId: txData.TransactionID.String(), + Location: ev.Location, + }) + for _, state := range txData.InfoStates { + res.InfoStates = append(res.InfoStates, &prototk.StateUpdate{ + Id: state.String(), + TransactionId: txData.TransactionID.String(), + }) + } +} + +func (n *Noto) wrapHookTransaction(domainConfig *types.NotoParsedConfig, functionABI *abi.Entry, params any) (pldapi.TransactionType, *abi.Entry, tktypes.HexBytes, error) { + if domainConfig.Options.Hooks.DevUsePublicHooks { + paramsJSON, err := json.Marshal(params) + return pldapi.TransactionTypePublic, functionABI, paramsJSON, err + } + + functionABI = penteInvokeABI(functionABI.Name, functionABI.Inputs) + penteParams := &PenteInvokeParams{ + Group: domainConfig.Options.Hooks.PrivateGroup, + To: domainConfig.Options.Hooks.PrivateAddress, + Inputs: params, + } + paramsJSON, err := json.Marshal(penteParams) + return pldapi.TransactionTypePrivate, functionABI, paramsJSON, err +} + +func mapSendTransactionType(transactionType pldapi.TransactionType) prototk.TransactionInput_TransactionType { + if transactionType == pldapi.TransactionTypePrivate { + return prototk.TransactionInput_PRIVATE + } + return prototk.TransactionInput_PUBLIC +} + +func mapPrepareTransactionType(transactionType pldapi.TransactionType) prototk.PreparedTransaction_TransactionType { + if transactionType == pldapi.TransactionTypePrivate { + return prototk.PreparedTransaction_PRIVATE + } + return prototk.PreparedTransaction_PUBLIC +} + func (n *Noto) HandleEventBatch(ctx context.Context, req *prototk.HandleEventBatchRequest) (*prototk.HandleEventBatchResponse, error) { var res prototk.HandleEventBatchResponse for _, ev := range req.Events { switch ev.SoliditySignature { - case n.transferSignature: + case n.eventSignatures[NotoTransfer]: + log.L(ctx).Infof("Processing '%s' event in batch %s", ev.SoliditySignature, req.BatchId) var transfer NotoTransfer_Event if err := json.Unmarshal([]byte(ev.DataJson), &transfer); err == nil { txData, err := n.decodeTransactionData(ctx, transfer.Data) if err != nil { return nil, err } - res.TransactionsComplete = append(res.TransactionsComplete, &prototk.CompletedTransaction{ - TransactionId: txData.TransactionID.String(), - Location: ev.Location, - }) + n.recordTransactionInfo(ev, txData, &res) res.SpentStates = append(res.SpentStates, n.parseStatesFromEvent(txData.TransactionID, transfer.Inputs)...) res.ConfirmedStates = append(res.ConfirmedStates, n.parseStatesFromEvent(txData.TransactionID, transfer.Outputs)...) - for _, state := range txData.InfoStates { - res.InfoStates = append(res.InfoStates, &prototk.StateUpdate{ - Id: state.String(), - TransactionId: txData.TransactionID.String(), - }) - } + } else { + log.L(ctx).Warnf("Ignoring malformed NotoTransfer event in batch %s: %s", req.BatchId, err) } - case n.approvedSignature: + case n.eventSignatures[NotoApproved]: + log.L(ctx).Infof("Processing '%s' event in batch %s", ev.SoliditySignature, req.BatchId) var approved NotoApproved_Event if err := json.Unmarshal([]byte(ev.DataJson), &approved); err == nil { txData, err := n.decodeTransactionData(ctx, approved.Data) if err != nil { return nil, err } - res.TransactionsComplete = append(res.TransactionsComplete, &prototk.CompletedTransaction{ - TransactionId: txData.TransactionID.String(), - Location: ev.Location, - }) - for _, state := range txData.InfoStates { - res.InfoStates = append(res.InfoStates, &prototk.StateUpdate{ - Id: state.String(), - TransactionId: txData.TransactionID.String(), - }) + n.recordTransactionInfo(ev, txData, &res) + } else { + log.L(ctx).Warnf("Ignoring malformed NotoApproved event in batch %s: %s", req.BatchId, err) + } + + case n.eventSignatures[NotoLock]: + log.L(ctx).Infof("Processing '%s' event in batch %s", ev.SoliditySignature, req.BatchId) + var lock NotoLock_Event + if err := json.Unmarshal([]byte(ev.DataJson), &lock); err == nil { + txData, err := n.decodeTransactionData(ctx, lock.Data) + if err != nil { + return nil, err } + n.recordTransactionInfo(ev, txData, &res) + res.SpentStates = append(res.SpentStates, n.parseStatesFromEvent(txData.TransactionID, lock.Inputs)...) + res.ConfirmedStates = append(res.ConfirmedStates, n.parseStatesFromEvent(txData.TransactionID, lock.Outputs)...) + res.ConfirmedStates = append(res.ConfirmedStates, n.parseStatesFromEvent(txData.TransactionID, lock.LockedOutputs)...) + } else { + log.L(ctx).Warnf("Ignoring malformed NotoLock event in batch %s: %s", req.BatchId, err) + } + + case n.eventSignatures[NotoUnlock]: + log.L(ctx).Infof("Processing '%s' event in batch %s", ev.SoliditySignature, req.BatchId) + var unlock NotoUnlock_Event + if err := json.Unmarshal([]byte(ev.DataJson), &unlock); err == nil { + txData, err := n.decodeTransactionData(ctx, unlock.Data) + if err != nil { + return nil, err + } + n.recordTransactionInfo(ev, txData, &res) + res.SpentStates = append(res.SpentStates, n.parseStatesFromEvent(txData.TransactionID, unlock.LockedInputs)...) + res.ConfirmedStates = append(res.ConfirmedStates, n.parseStatesFromEvent(txData.TransactionID, unlock.LockedOutputs)...) + res.ConfirmedStates = append(res.ConfirmedStates, n.parseStatesFromEvent(txData.TransactionID, unlock.Outputs)...) + + var domainConfig *types.NotoParsedConfig + err = json.Unmarshal([]byte(req.ContractInfo.ContractConfigJson), &domainConfig) + if err != nil { + return nil, err + } + if domainConfig.IsNotary && domainConfig.NotaryMode == types.NotaryModeHooks.Enum() && !domainConfig.Options.Hooks.PublicAddress.Equals(unlock.Sender) { + err = n.handleNotaryPrivateUnlock(ctx, req.StateQueryContext, domainConfig, &unlock) + if err != nil { + // Should all errors cause retry? + log.L(ctx).Errorf("Failed to handle NotoUnlock event in batch %s: %s", req.BatchId, err) + return nil, err + } + } + } else { + log.L(ctx).Warnf("Ignoring malformed NotoUnlock event in batch %s: %s", req.BatchId, err) + } + + case n.eventSignatures[NotoUnlockPrepared]: + log.L(ctx).Infof("Processing '%s' event in batch %s", ev.SoliditySignature, req.BatchId) + var unlockPrepared NotoUnlockPrepared_Event + if err := json.Unmarshal([]byte(ev.DataJson), &unlockPrepared); err == nil { + txData, err := n.decodeTransactionData(ctx, unlockPrepared.Data) + if err != nil { + return nil, err + } + n.recordTransactionInfo(ev, txData, &res) + res.ReadStates = append(res.ReadStates, n.parseStatesFromEvent(txData.TransactionID, unlockPrepared.LockedInputs)...) + } else { + log.L(ctx).Warnf("Ignoring malformed NotoUnlockPrepared event in batch %s: %s", req.BatchId, err) + } + + case n.eventSignatures[NotoLockDelegated]: + log.L(ctx).Infof("Processing '%s' event in batch %s", ev.SoliditySignature, req.BatchId) + var lockDelegated NotoLockDelegated_Event + if err := json.Unmarshal([]byte(ev.DataJson), &lockDelegated); err == nil { + txData, err := n.decodeTransactionData(ctx, lockDelegated.Data) + if err != nil { + return nil, err + } + n.recordTransactionInfo(ev, txData, &res) + } else { + log.L(ctx).Warnf("Ignoring malformed NotoLockDelegated event in batch %s: %s", req.BatchId, err) } } } return &res, nil } +// When notary logic is implemented via Pente, unlock events from the base ledger must be propagated back to the Pente hooks +// TODO: this method should not be invoked directly on the event loop, but rather via a queue +func (n *Noto) handleNotaryPrivateUnlock(ctx context.Context, stateQueryContext string, domainConfig *types.NotoParsedConfig, unlock *NotoUnlock_Event) error { + lockedInputs := make([]string, len(unlock.LockedInputs)) + for i, input := range unlock.LockedInputs { + lockedInputs[i] = input.String() + } + unlockedOutputs := make([]string, len(unlock.Outputs)) + for i, output := range unlock.Outputs { + unlockedOutputs[i] = output.String() + } + + inputStates, err := n.getStates(ctx, stateQueryContext, n.lockedCoinSchema.Id, lockedInputs) + if err != nil { + return err + } + if len(inputStates) != len(lockedInputs) { + return i18n.NewError(ctx, msgs.MsgMissingStateData, unlock.LockedInputs) + } + + outputStates, err := n.getStates(ctx, stateQueryContext, n.coinSchema.Id, unlockedOutputs) + if err != nil { + return err + } + if len(outputStates) != len(unlock.Outputs) { + return i18n.NewError(ctx, msgs.MsgMissingStateData, unlock.Outputs) + } + + recipients := make([]*ResolvedUnlockRecipient, len(outputStates)) + for i, state := range outputStates { + coin, err := n.unmarshalLockedCoin(state.DataJson) + if err != nil { + return err + } + recipients[i] = &ResolvedUnlockRecipient{ + To: coin.Owner, + Amount: coin.Amount, + } + } + + transactionType, functionABI, paramsJSON, err := n.wrapHookTransaction( + domainConfig, + solutils.MustLoadBuild(notoHooksJSON).ABI.Functions()["handleDelegateUnlock"], + &DelegateUnlockHookParams{ + Sender: unlock.Sender, + LockID: unlock.LockID, + Recipients: recipients, + Data: unlock.Data, + }, + ) + if err != nil { + return err + } + functionABIJSON, err := json.Marshal(functionABI) + if err != nil { + return err + } + + _, err = n.Callbacks.SendTransaction(ctx, &prototk.SendTransactionRequest{ + Transaction: &prototk.TransactionInput{ + Type: mapSendTransactionType(transactionType), + From: domainConfig.NotaryLookup, + ContractAddress: domainConfig.Options.Hooks.PublicAddress.String(), + FunctionAbiJson: string(functionABIJSON), + ParamsJson: string(paramsJSON), + }, + }) + return err +} + func (n *Noto) Sign(ctx context.Context, req *prototk.SignRequest) (*prototk.SignResponse, error) { return nil, i18n.NewError(ctx, msgs.MsgNotImplemented) } @@ -594,7 +972,97 @@ func (n *Noto) ExecCall(ctx context.Context, req *prototk.ExecCallRequest) (*pro return nil, i18n.NewError(ctx, msgs.MsgNotImplemented) } -func (n *Noto) BuildReceipt(ctx context.Context, req *prototk.BuildReceiptRequest) (*prototk.BuildReceiptResponse, error) { - // TODO: Event logs for transfers would be great for Noto - return nil, i18n.NewError(ctx, msgs.MsgNoDomainReceipt) +func (n *Noto) receiptStates(ctx context.Context, states []*prototk.EndorsableState) ([]*types.ReceiptState, error) { + coins := make([]*types.ReceiptState, len(states)) + for i, state := range states { + id, err := tktypes.ParseHexBytes(ctx, state.Id) + if err != nil { + return nil, err + } + coins[i] = &types.ReceiptState{ + ID: id, + Data: tktypes.RawJSON(state.StateDataJson), + } + } + return coins, nil +} + +func (n *Noto) BuildReceipt(ctx context.Context, req *prototk.BuildReceiptRequest) (res *prototk.BuildReceiptResponse, err error) { + receipt := &types.NotoDomainReceipt{} + + infoStates := n.filterSchema(req.InfoStates, []string{n.dataSchema.Id}) + if len(infoStates) == 1 { + info, err := n.unmarshalInfo(infoStates[0].StateDataJson) + if err != nil { + return nil, err + } + receipt.Data = info.Data + } + + lockStates := n.filterSchema(req.InfoStates, []string{n.lockInfoSchema.Id}) + if len(lockStates) == 1 { + lock, err := n.unmarshalLock(lockStates[0].StateDataJson) + if err != nil { + return nil, err + } + receipt.LockInfo = &types.ReceiptLockInfo{LockID: lock.LockID} + if !lock.Delegate.IsZero() { + receipt.LockInfo.Delegate = lock.Delegate + } + } + + receipt.States.Inputs, err = n.receiptStates(ctx, n.filterSchema(req.InputStates, []string{n.coinSchema.Id})) + if err == nil { + receipt.States.LockedInputs, err = n.receiptStates(ctx, n.filterSchema(req.InputStates, []string{n.lockedCoinSchema.Id})) + } + if err == nil { + receipt.States.Outputs, err = n.receiptStates(ctx, n.filterSchema(req.OutputStates, []string{n.coinSchema.Id})) + } + if err == nil { + receipt.States.LockedOutputs, err = n.receiptStates(ctx, n.filterSchema(req.OutputStates, []string{n.lockedCoinSchema.Id})) + } + if err == nil { + receipt.States.ReadInputs, err = n.receiptStates(ctx, n.filterSchema(req.ReadStates, []string{n.coinSchema.Id})) + } + if err == nil { + receipt.States.ReadLockedInputs, err = n.receiptStates(ctx, n.filterSchema(req.ReadStates, []string{n.lockedCoinSchema.Id})) + } + if err == nil { + receipt.States.PreparedOutputs, err = n.receiptStates(ctx, n.filterSchema(req.InfoStates, []string{n.coinSchema.Id})) + } + if err == nil { + receipt.States.PreparedLockedOutputs, err = n.receiptStates(ctx, n.filterSchema(req.InfoStates, []string{n.lockedCoinSchema.Id})) + } + if err != nil { + return nil, err + } + + if receipt.LockInfo != nil && len(receipt.States.ReadLockedInputs) > 0 && len(receipt.States.PreparedOutputs) > 0 { + // For prepareUnlock transactions, include the encoded "unlock" call that can be used to unlock the coins + unlock := n.contractABI.Functions()["unlock"] + params := &NotoUnlockParams{ + LockID: receipt.LockInfo.LockID, + LockedInputs: endorsableStateIDs(n.filterSchema(req.ReadStates, []string{n.lockedCoinSchema.Id})), + LockedOutputs: endorsableStateIDs(n.filterSchema(req.InfoStates, []string{n.lockedCoinSchema.Id})), + Outputs: endorsableStateIDs(n.filterSchema(req.InfoStates, []string{n.coinSchema.Id})), + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + encodedCall, err := unlock.EncodeCallDataJSONCtx(ctx, paramsJSON) + if err != nil { + return nil, err + } + receipt.LockInfo.Unlock = encodedCall + } + + receiptJSON, err := json.Marshal(receipt) + if err != nil { + return nil, err + } + + return &prototk.BuildReceiptResponse{ + ReceiptJson: string(receiptJSON), + }, nil } diff --git a/domains/noto/internal/noto/noto_test.go b/domains/noto/internal/noto/noto_test.go index a44b5da29..aee6caec4 100644 --- a/domains/noto/internal/noto/noto_test.go +++ b/domains/noto/internal/noto/noto_test.go @@ -17,10 +17,12 @@ package noto import ( "context" + "encoding/json" "fmt" "testing" "github.com/kaleido-io/paladin/domains/noto/pkg/types" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -28,7 +30,7 @@ import ( ) var encodedConfig = func() []byte { - configData := tktypes.HexBytes(`{"notaryLookup":"notary"}`) + configData := tktypes.HexBytes(`{"notaryLookup":"notary@node1"}`) encoded, err := types.NotoConfigABI_V0.EncodeABIDataJSON([]byte(fmt.Sprintf(`{ "notaryAddress": "0x138baffcdcc3543aad1afd81c71d2182cdf9c8cd", "variant": "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -43,8 +45,16 @@ var encodedConfig = func() []byte { return result }() +var mockCallbacks = &domain.MockDomainCallbacks{ + MockLocalNodeName: func() (*prototk.LocalNodeNameResponse, error) { + return &prototk.LocalNodeNameResponse{ + Name: "node1", + }, nil + }, +} + func TestConfigureDomainBadConfig(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ ConfigJson: "!!wrong", }) @@ -52,7 +62,7 @@ func TestConfigureDomainBadConfig(t *testing.T) { } func TestInitDeployBadParams(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitDeploy(context.Background(), &prototk.InitDeployRequest{ Transaction: &prototk.DeployTransactionSpecification{ ConstructorParamsJson: "!!wrong", @@ -62,7 +72,7 @@ func TestInitDeployBadParams(t *testing.T) { } func TestPrepareDeployBadParams(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.PrepareDeploy(context.Background(), &prototk.PrepareDeployRequest{ Transaction: &prototk.DeployTransactionSpecification{ ConstructorParamsJson: "!!wrong", @@ -72,7 +82,7 @@ func TestPrepareDeployBadParams(t *testing.T) { } func TestPrepareDeployMissingVerifier(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.PrepareDeploy(context.Background(), &prototk.PrepareDeployRequest{ Transaction: &prototk.DeployTransactionSpecification{ ConstructorParamsJson: "{}", @@ -82,7 +92,7 @@ func TestPrepareDeployMissingVerifier(t *testing.T) { } func TestInitTransactionBadAbi(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitTransaction(context.Background(), &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ FunctionAbiJson: "!!wrong", @@ -92,7 +102,7 @@ func TestInitTransactionBadAbi(t *testing.T) { } func TestInitTransactionBadFunction(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitTransaction(context.Background(), &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ ContractInfo: &prototk.ContractInfo{ @@ -105,24 +115,30 @@ func TestInitTransactionBadFunction(t *testing.T) { } func TestInitContractOk(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} res, err := n.InitContract(context.Background(), &prototk.InitContractRequest{ ContractAddress: tktypes.RandAddress().String(), ContractConfig: encodedConfig, }) require.NoError(t, err) require.JSONEq(t, `{ - "notaryAddress": "0x138baffcdcc3543aad1afd81c71d2182cdf9c8cd", - "notaryLookup": "notary", - "notaryType": "0x0", - "restrictMinting": false, - "allowBurning": false, - "variant": "0x0" + "notaryLookup": "notary@node1", + "notaryMode": "basic", + "isNotary": true, + "variant": "0x0", + "options": { + "basic": { + "restrictMint": false, + "allowBurn": false, + "allowLock": false, + "restrictUnlock": false + } + } }`, res.ContractConfig.ContractConfigJson) } func TestInitTransactionBadParams(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitTransaction(context.Background(), &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ ContractInfo: &prototk.ContractInfo{ @@ -136,7 +152,7 @@ func TestInitTransactionBadParams(t *testing.T) { } func TestInitTransactionMissingTo(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitTransaction(context.Background(), &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ ContractInfo: &prototk.ContractInfo{ @@ -150,7 +166,7 @@ func TestInitTransactionMissingTo(t *testing.T) { } func TestInitTransactionMissingAmount(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitTransaction(context.Background(), &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ ContractInfo: &prototk.ContractInfo{ @@ -164,7 +180,7 @@ func TestInitTransactionMissingAmount(t *testing.T) { } func TestInitTransactionBadSignature(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.InitTransaction(context.Background(), &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ ContractInfo: &prototk.ContractInfo{ @@ -178,7 +194,7 @@ func TestInitTransactionBadSignature(t *testing.T) { } func TestAssembleTransactionBadAbi(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.AssembleTransaction(context.Background(), &prototk.AssembleTransactionRequest{ Transaction: &prototk.TransactionSpecification{ FunctionAbiJson: "!!wrong", @@ -188,7 +204,7 @@ func TestAssembleTransactionBadAbi(t *testing.T) { } func TestEndorseTransactionBadAbi(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.EndorseTransaction(context.Background(), &prototk.EndorseTransactionRequest{ Transaction: &prototk.TransactionSpecification{ FunctionAbiJson: "!!wrong", @@ -198,7 +214,7 @@ func TestEndorseTransactionBadAbi(t *testing.T) { } func TestPrepareTransactionBadAbi(t *testing.T) { - n := &Noto{} + n := &Noto{Callbacks: mockCallbacks} _, err := n.PrepareTransaction(context.Background(), &prototk.PrepareTransactionRequest{ Transaction: &prototk.TransactionSpecification{ FunctionAbiJson: "!!wrong", @@ -206,3 +222,281 @@ func TestPrepareTransactionBadAbi(t *testing.T) { }) assert.ErrorContains(t, err, "invalid character") } + +func TestHandleEventBatch_NotoTransfer(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + input := tktypes.RandBytes32() + output := tktypes.RandBytes32() + event := &NotoTransfer_Event{ + Inputs: []tktypes.Bytes32{input}, + Outputs: []tktypes.Bytes32{output}, + Signature: tktypes.MustParseHexBytes("0x1234"), + Data: tktypes.MustParseHexBytes("0x"), + } + notoEventJson, err := json.Marshal(event) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoTransfer], + DataJson: string(notoEventJson), + }, + }, + } + + res, err := n.HandleEventBatch(ctx, req) + require.NoError(t, err) + require.Len(t, res.TransactionsComplete, 1) + require.Len(t, res.SpentStates, 1) + assert.Equal(t, input.String(), res.SpentStates[0].Id) + require.Len(t, res.ConfirmedStates, 1) + assert.Equal(t, output.String(), res.ConfirmedStates[0].Id) +} + +func TestHandleEventBatch_NotoTransferBadData(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoTransfer], + DataJson: "!!wrong", + }}, + } + + res, err := n.HandleEventBatch(ctx, req) + require.NoError(t, err) + require.Len(t, res.TransactionsComplete, 0) + require.Len(t, res.SpentStates, 0) + require.Len(t, res.ConfirmedStates, 0) +} + +func TestHandleEventBatch_NotoTransferBadTransactionData(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + event := &NotoTransfer_Event{ + Data: tktypes.MustParseHexBytes("0x00010000"), + } + notoEventJson, err := json.Marshal(event) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoTransfer], + DataJson: string(notoEventJson), + }}, + } + + _, err = n.HandleEventBatch(ctx, req) + require.ErrorContains(t, err, "FF22047") +} + +func TestHandleEventBatch_NotoLock(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + input := tktypes.RandBytes32() + output := tktypes.RandBytes32() + lockedOutput := tktypes.RandBytes32() + event := &NotoLock_Event{ + LockID: tktypes.RandBytes32(), + Inputs: []tktypes.Bytes32{input}, + Outputs: []tktypes.Bytes32{output}, + LockedOutputs: []tktypes.Bytes32{lockedOutput}, + Signature: tktypes.MustParseHexBytes("0x1234"), + Data: tktypes.MustParseHexBytes("0x"), + } + notoEventJson, err := json.Marshal(event) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoLock], + DataJson: string(notoEventJson), + }, + }, + } + + res, err := n.HandleEventBatch(ctx, req) + require.NoError(t, err) + require.Len(t, res.TransactionsComplete, 1) + require.Len(t, res.SpentStates, 1) + assert.Equal(t, input.String(), res.SpentStates[0].Id) + require.Len(t, res.ConfirmedStates, 2) + assert.Equal(t, output.String(), res.ConfirmedStates[0].Id) + assert.Equal(t, lockedOutput.String(), res.ConfirmedStates[1].Id) +} + +func TestHandleEventBatch_NotoLockBadData(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoLock], + DataJson: "!!wrong", + }}, + } + + res, err := n.HandleEventBatch(ctx, req) + require.NoError(t, err) + require.Len(t, res.TransactionsComplete, 0) + require.Len(t, res.SpentStates, 0) + require.Len(t, res.ConfirmedStates, 0) +} + +func TestHandleEventBatch_NotoLockBadTransactionData(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + event := &NotoTransfer_Event{ + Data: tktypes.MustParseHexBytes("0x00010000"), + } + notoEventJson, err := json.Marshal(event) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoLock], + DataJson: string(notoEventJson), + }}, + } + + _, err = n.HandleEventBatch(ctx, req) + require.ErrorContains(t, err, "FF22047") +} + +func TestHandleEventBatch_NotoUnlock(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + lockedInput := tktypes.RandBytes32() + output := tktypes.RandBytes32() + lockedOutput := tktypes.RandBytes32() + event := &NotoUnlock_Event{ + LockID: tktypes.RandBytes32(), + LockedInputs: []tktypes.Bytes32{lockedInput}, + LockedOutputs: []tktypes.Bytes32{lockedOutput}, + Outputs: []tktypes.Bytes32{output}, + Signature: tktypes.MustParseHexBytes("0x1234"), + Data: tktypes.MustParseHexBytes("0x"), + } + notoEventJson, err := json.Marshal(event) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoUnlock], + DataJson: string(notoEventJson), + }, + }, + ContractInfo: &prototk.ContractInfo{ + ContractConfigJson: `{}`, + }, + } + + res, err := n.HandleEventBatch(ctx, req) + require.NoError(t, err) + require.Len(t, res.TransactionsComplete, 1) + require.Len(t, res.SpentStates, 1) + assert.Equal(t, lockedInput.String(), res.SpentStates[0].Id) + require.Len(t, res.ConfirmedStates, 2) + assert.Equal(t, lockedOutput.String(), res.ConfirmedStates[0].Id) + assert.Equal(t, output.String(), res.ConfirmedStates[1].Id) +} + +func TestHandleEventBatch_NotoUnlockBadData(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoUnlock], + DataJson: "!!wrong", + }}, + } + + res, err := n.HandleEventBatch(ctx, req) + require.NoError(t, err) + require.Len(t, res.TransactionsComplete, 0) + require.Len(t, res.SpentStates, 0) + require.Len(t, res.ConfirmedStates, 0) +} + +func TestHandleEventBatch_NotoUnlockBadTransactionData(t *testing.T) { + n := &Noto{Callbacks: mockCallbacks} + ctx := context.Background() + + _, err := n.ConfigureDomain(context.Background(), &prototk.ConfigureDomainRequest{ + ConfigJson: `{}`, + }) + require.NoError(t, err) + + event := &NotoTransfer_Event{ + Data: tktypes.MustParseHexBytes("0x00010000"), + } + notoEventJson, err := json.Marshal(event) + require.NoError(t, err) + + req := &prototk.HandleEventBatchRequest{ + Events: []*prototk.OnChainEvent{ + { + SoliditySignature: n.eventSignatures[NotoUnlock], + DataJson: string(notoEventJson), + }}, + } + + _, err = n.HandleEventBatch(ctx, req) + require.ErrorContains(t, err, "FF22047") +} diff --git a/domains/noto/internal/noto/states.go b/domains/noto/internal/noto/states.go index 703e9ff5a..4d15cfc41 100644 --- a/domains/noto/internal/noto/states.go +++ b/domains/noto/internal/noto/states.go @@ -19,6 +19,7 @@ import ( "context" "encoding/json" "math/big" + "slices" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-signer/pkg/eip712" @@ -26,6 +27,7 @@ import ( "github.com/kaleido-io/paladin/domains/noto/internal/msgs" "github.com/kaleido-io/paladin/domains/noto/pkg/types" "github.com/kaleido-io/paladin/toolkit/pkg/log" + "github.com/kaleido-io/paladin/toolkit/pkg/pldapi" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/query" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" @@ -33,23 +35,33 @@ import ( var EIP712DomainName = "noto" var EIP712DomainVersion = "0.0.1" +var EIP712DomainType = eip712.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, +} + +var NotoCoinType = eip712.Type{ + {Name: "salt", Type: "bytes32"}, + {Name: "owner", Type: "address"}, + {Name: "amount", Type: "uint256"}, +} + +var NotoLockedCoinType = eip712.Type{ + {Name: "salt", Type: "bytes32"}, + {Name: "lockId", Type: "bytes32"}, + {Name: "owner", Type: "address"}, + {Name: "amount", Type: "uint256"}, +} var NotoTransferUnmaskedTypeSet = eip712.TypeSet{ "Transfer": { {Name: "inputs", Type: "Coin[]"}, {Name: "outputs", Type: "Coin[]"}, }, - "Coin": { - {Name: "salt", Type: "bytes32"}, - {Name: "owner", Type: "address"}, - {Name: "amount", Type: "uint256"}, - }, - eip712.EIP712Domain: { - {Name: "name", Type: "string"}, - {Name: "version", Type: "string"}, - {Name: "chainId", Type: "uint256"}, - {Name: "verifyingContract", Type: "address"}, - }, + "Coin": NotoCoinType, + eip712.EIP712Domain: EIP712DomainType, } var NotoTransferMaskedTypeSet = eip712.TypeSet{ @@ -58,12 +70,48 @@ var NotoTransferMaskedTypeSet = eip712.TypeSet{ {Name: "outputs", Type: "bytes32[]"}, {Name: "data", Type: "bytes"}, }, - eip712.EIP712Domain: { - {Name: "name", Type: "string"}, - {Name: "version", Type: "string"}, - {Name: "chainId", Type: "uint256"}, - {Name: "verifyingContract", Type: "address"}, + eip712.EIP712Domain: EIP712DomainType, +} + +var NotoLockTypeSet = eip712.TypeSet{ + "Lock": { + {Name: "inputs", Type: "Coin[]"}, + {Name: "outputs", Type: "Coin[]"}, + {Name: "lockedOutputs", Type: "LockedCoin[]"}, + }, + "LockedCoin": NotoLockedCoinType, + "Coin": NotoCoinType, + eip712.EIP712Domain: EIP712DomainType, +} + +var NotoUnlockTypeSet = eip712.TypeSet{ + "Unlock": { + {Name: "lockedInputs", Type: "LockedCoin[]"}, + {Name: "lockedOutputs", Type: "LockedCoin[]"}, + {Name: "outputs", Type: "Coin[]"}, + }, + "LockedCoin": NotoLockedCoinType, + "Coin": NotoCoinType, + eip712.EIP712Domain: EIP712DomainType, +} + +var NotoUnlockMaskedTypeSet = eip712.TypeSet{ + "Unlock": { + {Name: "lockedInputs", Type: "bytes32[]"}, + {Name: "lockedOutputs", Type: "bytes32[]"}, + {Name: "outputs", Type: "bytes32[]"}, + {Name: "data", Type: "bytes"}, + }, + eip712.EIP712Domain: EIP712DomainType, +} + +var NotoDelegateLockTypeSet = eip712.TypeSet{ + "DelegateLock": { + {Name: "lockId", Type: "bytes32"}, + {Name: "delegate", Type: "address"}, + {Name: "data", Type: "bytes"}, }, + eip712.EIP712Domain: EIP712DomainType, } func (n *Noto) unmarshalCoin(stateData string) (*types.NotoCoin, error) { @@ -72,6 +120,24 @@ func (n *Noto) unmarshalCoin(stateData string) (*types.NotoCoin, error) { return &coin, err } +func (n *Noto) unmarshalLockedCoin(stateData string) (*types.NotoLockedCoin, error) { + var coin types.NotoLockedCoin + err := json.Unmarshal([]byte(stateData), &coin) + return &coin, err +} + +func (n *Noto) unmarshalInfo(stateData string) (*types.TransactionData, error) { + var info types.TransactionData + err := json.Unmarshal([]byte(stateData), &info) + return &info, err +} + +func (n *Noto) unmarshalLock(stateData string) (*types.NotoLockInfo, error) { + var lock types.NotoLockInfo + err := json.Unmarshal([]byte(stateData), &lock) + return &lock, err +} + func (n *Noto) makeNewCoinState(coin *types.NotoCoin, distributionList []string) (*prototk.NewState, error) { coinJSON, err := json.Marshal(coin) if err != nil { @@ -84,6 +150,18 @@ func (n *Noto) makeNewCoinState(coin *types.NotoCoin, distributionList []string) }, nil } +func (n *Noto) makeNewLockedCoinState(coin *types.NotoLockedCoin, distributionList []string) (*prototk.NewState, error) { + coinJSON, err := json.Marshal(coin) + if err != nil { + return nil, err + } + return &prototk.NewState{ + SchemaId: n.lockedCoinSchema.Id, + StateDataJson: string(coinJSON), + DistributionList: distributionList, + }, nil +} + func (n *Noto) makeNewInfoState(info *types.TransactionData, distributionList []string) (*prototk.NewState, error) { infoJSON, err := json.Marshal(info) if err != nil { @@ -96,7 +174,41 @@ func (n *Noto) makeNewInfoState(info *types.TransactionData, distributionList [] }, nil } -func (n *Noto) prepareInputs(ctx context.Context, stateQueryContext string, owner *tktypes.EthAddress, amount *tktypes.HexUint256) ([]*types.NotoCoin, []*prototk.StateRef, *big.Int, error) { +func (n *Noto) makeNewLockState(lock *types.NotoLockInfo, distributionList []string) (*prototk.NewState, error) { + lockJSON, err := json.Marshal(lock) + if err != nil { + return nil, err + } + return &prototk.NewState{ + SchemaId: n.lockInfoSchema.Id, + StateDataJson: string(lockJSON), + DistributionList: distributionList, + }, nil +} + +type preparedInputs struct { + coins []*types.NotoCoin + states []*prototk.StateRef + total *big.Int +} + +type preparedLockedInputs struct { + coins []*types.NotoLockedCoin + states []*prototk.StateRef + total *big.Int +} + +type preparedOutputs struct { + coins []*types.NotoCoin + states []*prototk.NewState +} + +type preparedLockedOutputs struct { + coins []*types.NotoLockedCoin + states []*prototk.NewState +} + +func (n *Noto) prepareInputs(ctx context.Context, stateQueryContext string, owner *tktypes.EthAddress, amount *tktypes.HexUint256) (inputs *preparedInputs, revert bool, err error) { var lastStateTimestamp int64 total := big.NewInt(0) stateRefs := []*prototk.StateRef{} @@ -113,19 +225,18 @@ func (n *Noto) prepareInputs(ctx context.Context, stateQueryContext string, owne } log.L(ctx).Debugf("State query: %s", queryBuilder.Query()) - states, err := n.findAvailableStates(ctx, stateQueryContext, queryBuilder.Query().String()) - + states, err := n.findAvailableStates(ctx, stateQueryContext, n.coinSchema.Id, queryBuilder.Query().String()) if err != nil { - return nil, nil, nil, err + return nil, false, err } if len(states) == 0 { - return nil, nil, nil, i18n.NewError(ctx, msgs.MsgInsufficientFunds, total.Text(10)) + return nil, true, i18n.NewError(ctx, msgs.MsgInsufficientFunds, total.Text(10)) } for _, state := range states { lastStateTimestamp = state.CreatedAt coin, err := n.unmarshalCoin(state.DataJson) if err != nil { - return nil, nil, nil, i18n.NewError(ctx, msgs.MsgInvalidStateData, state.Id, err) + return nil, false, i18n.NewError(ctx, msgs.MsgInvalidStateData, state.Id, err) } total = total.Add(total, coin.Amount.Int()) stateRefs = append(stateRefs, &prototk.StateRef{ @@ -135,22 +246,95 @@ func (n *Noto) prepareInputs(ctx context.Context, stateQueryContext string, owne coins = append(coins, coin) log.L(ctx).Debugf("Selecting coin %s value=%s total=%s required=%s)", state.Id, coin.Amount.Int().Text(10), total.Text(10), amount.Int().Text(10)) if total.Cmp(amount.Int()) >= 0 { - return coins, stateRefs, total, nil + return &preparedInputs{ + coins: coins, + states: stateRefs, + total: total, + }, false, nil } } } } -func (n *Noto) prepareOutputs(ownerAddress *tktypes.EthAddress, amount *tktypes.HexUint256, distributionList []string) ([]*types.NotoCoin, []*prototk.NewState, error) { +func (n *Noto) prepareLockedInputs(ctx context.Context, stateQueryContext string, lockID tktypes.Bytes32, owner *tktypes.EthAddress, amount *big.Int) (inputs *preparedLockedInputs, revert bool, err error) { + var lastStateTimestamp int64 + total := big.NewInt(0) + stateRefs := []*prototk.StateRef{} + coins := []*types.NotoLockedCoin{} + + for { + queryBuilder := query.NewQueryBuilder(). + Limit(10). + Sort(".created"). + Equal("lockId", lockID). + Equal("owner", owner.String()) + + if lastStateTimestamp > 0 { + queryBuilder.GreaterThan(".created", lastStateTimestamp) + } + + log.L(ctx).Debugf("State query: %s", queryBuilder.Query()) + states, err := n.findAvailableStates(ctx, stateQueryContext, n.lockedCoinSchema.Id, queryBuilder.Query().String()) + + if err != nil { + return nil, false, err + } + if len(states) == 0 { + return nil, true, i18n.NewError(ctx, msgs.MsgInsufficientFunds, total.Text(10)) + } + for _, state := range states { + lastStateTimestamp = state.CreatedAt + coin, err := n.unmarshalLockedCoin(state.DataJson) + if err != nil { + return nil, false, i18n.NewError(ctx, msgs.MsgInvalidStateData, state.Id, err) + } + total = total.Add(total, coin.Amount.Int()) + stateRefs = append(stateRefs, &prototk.StateRef{ + SchemaId: state.SchemaId, + Id: state.Id, + }) + coins = append(coins, coin) + log.L(ctx).Debugf("Selecting coin %s value=%s total=%s required=%s)", state.Id, coin.Amount.Int().Text(10), total.Text(10), amount.Text(10)) + if total.Cmp(amount) >= 0 { + return &preparedLockedInputs{ + coins: coins, + states: stateRefs, + total: total, + }, false, nil + } + } + } +} + +func (n *Noto) prepareOutputs(ownerAddress *tktypes.EthAddress, amount *tktypes.HexUint256, distributionList []string) (*preparedOutputs, error) { // Always produce a single coin for the entire output amount // TODO: make this configurable newCoin := &types.NotoCoin{ - Salt: tktypes.RandHex(32), + Salt: tktypes.RandBytes32(), Owner: ownerAddress, Amount: amount, } newState, err := n.makeNewCoinState(newCoin, distributionList) - return []*types.NotoCoin{newCoin}, []*prototk.NewState{newState}, err + return &preparedOutputs{ + coins: []*types.NotoCoin{newCoin}, + states: []*prototk.NewState{newState}, + }, err +} + +func (n *Noto) prepareLockedOutputs(id tktypes.Bytes32, ownerAddress *tktypes.EthAddress, amount *tktypes.HexUint256, distributionList []string) (*preparedLockedOutputs, error) { + // Always produce a single coin for the entire output amount + // TODO: make this configurable + newCoin := &types.NotoLockedCoin{ + Salt: tktypes.RandBytes32(), + LockID: id, + Owner: ownerAddress, + Amount: amount, + } + newState, err := n.makeNewLockedCoinState(newCoin, distributionList) + return &preparedLockedOutputs{ + coins: []*types.NotoLockedCoin{newCoin}, + states: []*prototk.NewState{newState}, + }, err } func (n *Noto) prepareInfo(data tktypes.HexBytes, distributionList []string) ([]*prototk.NewState, error) { @@ -162,10 +346,50 @@ func (n *Noto) prepareInfo(data tktypes.HexBytes, distributionList []string) ([] return []*prototk.NewState{newState}, err } -func (n *Noto) findAvailableStates(ctx context.Context, stateQueryContext, query string) ([]*prototk.StoredState, error) { +func (n *Noto) prepareLockInfo(lockID tktypes.Bytes32, owner, delegate *tktypes.EthAddress, distributionList []string) (*prototk.NewState, error) { + if delegate == nil { + delegate = &tktypes.EthAddress{} + } + newData := &types.NotoLockInfo{ + Salt: tktypes.RandBytes32(), + LockID: lockID, + Owner: owner, + Delegate: delegate, + } + return n.makeNewLockState(newData, distributionList) + +} + +func (n *Noto) filterSchema(states []*prototk.EndorsableState, schemas []string) (filtered []*prototk.EndorsableState) { + for _, state := range states { + if slices.Contains(schemas, state.SchemaId) { + filtered = append(filtered, state) + } + } + return filtered +} + +func (n *Noto) splitStates(states []*prototk.EndorsableState) (unlocked []*prototk.EndorsableState, locked []*prototk.EndorsableState) { + return n.filterSchema(states, []string{n.coinSchema.Id}), n.filterSchema(states, []string{n.lockedCoinSchema.Id}) +} + +func (n *Noto) getStates(ctx context.Context, stateQueryContext, schemaId string, ids []string) ([]*prototk.StoredState, error) { + req := &prototk.GetStatesRequest{ + StateQueryContext: stateQueryContext, + SchemaId: schemaId, + StateIds: ids, + } + res, err := n.Callbacks.GetStates(ctx, req) + if err != nil { + return nil, err + } + return res.States, nil +} + +func (n *Noto) findAvailableStates(ctx context.Context, stateQueryContext, schemaId, query string) ([]*prototk.StoredState, error) { req := &prototk.FindAvailableStatesRequest{ StateQueryContext: stateQueryContext, - SchemaId: n.coinSchema.Id, + SchemaId: schemaId, QueryJson: query, } res, err := n.Callbacks.FindAvailableStates(ctx, req) @@ -175,8 +399,8 @@ func (n *Noto) findAvailableStates(ctx context.Context, stateQueryContext, query return res.States, nil } -func (n *Noto) eip712Domain(contract *ethtypes.Address0xHex) map[string]interface{} { - return map[string]interface{}{ +func (n *Noto) eip712Domain(contract *ethtypes.Address0xHex) map[string]any { + return map[string]any{ "name": EIP712DomainName, "version": EIP712DomainVersion, "chainId": n.chainID, @@ -184,43 +408,129 @@ func (n *Noto) eip712Domain(contract *ethtypes.Address0xHex) map[string]interfac } } -func (n *Noto) encodeTransferUnmasked(ctx context.Context, contract *ethtypes.Address0xHex, inputs, outputs []*types.NotoCoin) (ethtypes.HexBytes0xPrefix, error) { - messageInputs := make([]interface{}, len(inputs)) - for i, input := range inputs { - messageInputs[i] = map[string]interface{}{ - "salt": input.Salt, - "owner": input.Owner, - "amount": input.Amount.String(), +func (n *Noto) encodeNotoCoins(coins []*types.NotoCoin) []any { + encodedCoins := make([]any, len(coins)) + for i, coin := range coins { + encodedCoins[i] = map[string]any{ + "salt": coin.Salt, + "owner": coin.Owner, + "amount": coin.Amount.String(), } } - messageOutputs := make([]interface{}, len(outputs)) - for i, output := range outputs { - messageOutputs[i] = map[string]interface{}{ - "salt": output.Salt, - "owner": output.Owner, - "amount": output.Amount.String(), + return encodedCoins +} + +func (n *Noto) encodeNotoLockedCoins(coins []*types.NotoLockedCoin) []any { + encodedCoins := make([]any, len(coins)) + for i, coin := range coins { + encodedCoins[i] = map[string]any{ + "salt": coin.Salt, + "lockId": coin.LockID, + "owner": coin.Owner, + "amount": coin.Amount.String(), } } + return encodedCoins +} + +func encodedStateIDs(states []*pldapi.StateEncoded) []string { + inputs := make([]string, len(states)) + for i, state := range states { + inputs[i] = state.ID.String() + } + return inputs +} + +func endorsableStateIDs(states []*prototk.EndorsableState) []string { + inputs := make([]string, len(states)) + for i, state := range states { + inputs[i] = state.Id + } + return inputs +} + +func stringToAny(ids []string) []any { + result := make([]any, len(ids)) + for i, id := range ids { + result[i] = id + } + return result +} + +func (n *Noto) encodeTransferUnmasked(ctx context.Context, contract *ethtypes.Address0xHex, inputs, outputs []*types.NotoCoin) (ethtypes.HexBytes0xPrefix, error) { return eip712.EncodeTypedDataV4(ctx, &eip712.TypedData{ Types: NotoTransferUnmaskedTypeSet, PrimaryType: "Transfer", Domain: n.eip712Domain(contract), - Message: map[string]interface{}{ - "inputs": messageInputs, - "outputs": messageOutputs, + Message: map[string]any{ + "inputs": n.encodeNotoCoins(inputs), + "outputs": n.encodeNotoCoins(outputs), }, }) } -func (n *Noto) encodeTransferMasked(ctx context.Context, contract *ethtypes.Address0xHex, inputs, outputs []interface{}, data tktypes.HexBytes) (ethtypes.HexBytes0xPrefix, error) { +func (n *Noto) encodeTransferMasked(ctx context.Context, contract *ethtypes.Address0xHex, inputs, outputs []*pldapi.StateEncoded, data tktypes.HexBytes) (ethtypes.HexBytes0xPrefix, error) { return eip712.EncodeTypedDataV4(ctx, &eip712.TypedData{ Types: NotoTransferMaskedTypeSet, PrimaryType: "Transfer", Domain: n.eip712Domain(contract), - Message: map[string]interface{}{ - "inputs": inputs, - "outputs": outputs, + Message: map[string]any{ + "inputs": stringToAny(encodedStateIDs(inputs)), + "outputs": stringToAny(encodedStateIDs(outputs)), "data": data, }, }) } + +func (n *Noto) encodeLock(ctx context.Context, contract *ethtypes.Address0xHex, inputs, outputs []*types.NotoCoin, lockedOutputs []*types.NotoLockedCoin) (ethtypes.HexBytes0xPrefix, error) { + return eip712.EncodeTypedDataV4(ctx, &eip712.TypedData{ + Types: NotoLockTypeSet, + PrimaryType: "Lock", + Domain: n.eip712Domain(contract), + Message: map[string]any{ + "inputs": n.encodeNotoCoins(inputs), + "outputs": n.encodeNotoCoins(outputs), + "lockedOutputs": n.encodeNotoLockedCoins(lockedOutputs), + }, + }) +} + +func (n *Noto) encodeUnlock(ctx context.Context, contract *ethtypes.Address0xHex, lockedInputs, lockedOutputs []*types.NotoLockedCoin, outputs []*types.NotoCoin) (ethtypes.HexBytes0xPrefix, error) { + return eip712.EncodeTypedDataV4(ctx, &eip712.TypedData{ + Types: NotoUnlockTypeSet, + PrimaryType: "Unlock", + Domain: n.eip712Domain(contract), + Message: map[string]any{ + "lockedInputs": n.encodeNotoLockedCoins(lockedInputs), + "lockedOutputs": n.encodeNotoLockedCoins(lockedOutputs), + "outputs": n.encodeNotoCoins(outputs), + }, + }) +} + +func (n *Noto) encodeUnlockMasked(ctx context.Context, contract *ethtypes.Address0xHex, lockedInputs, lockedOutputs, outputs []*prototk.EndorsableState, data tktypes.HexBytes) (ethtypes.HexBytes0xPrefix, error) { + return eip712.EncodeTypedDataV4(ctx, &eip712.TypedData{ + Types: NotoUnlockMaskedTypeSet, + PrimaryType: "Unlock", + Domain: n.eip712Domain(contract), + Message: map[string]any{ + "lockedInputs": stringToAny(endorsableStateIDs(lockedInputs)), + "lockedOutputs": stringToAny(endorsableStateIDs(lockedOutputs)), + "outputs": stringToAny(endorsableStateIDs(outputs)), + "data": data, + }, + }) +} + +func (n *Noto) encodeDelegateLock(ctx context.Context, contract *ethtypes.Address0xHex, lockID tktypes.Bytes32, delegate *tktypes.EthAddress, data tktypes.HexBytes) (ethtypes.HexBytes0xPrefix, error) { + return eip712.EncodeTypedDataV4(ctx, &eip712.TypedData{ + Types: NotoDelegateLockTypeSet, + PrimaryType: "DelegateLock", + Domain: n.eip712Domain(contract), + Message: map[string]any{ + "lockId": lockID, + "delegate": delegate, + "data": data, + }, + }) +} diff --git a/domains/noto/pkg/noto/noto.go b/domains/noto/pkg/noto/noto.go index 8c2263df5..998a90792 100644 --- a/domains/noto/pkg/noto/noto.go +++ b/domains/noto/pkg/noto/noto.go @@ -26,6 +26,8 @@ type Noto interface { GetHandler(method string) types.DomainHandler Name() string CoinSchemaID() string + LockedCoinSchemaID() string + LockInfoSchemaID() string } func New(callbacks plugintk.DomainCallbacks) Noto { diff --git a/domains/noto/pkg/types/abi.go b/domains/noto/pkg/types/abi.go index 31235d595..22f303c85 100644 --- a/domains/noto/pkg/types/abi.go +++ b/domains/noto/pkg/types/abi.go @@ -30,18 +30,28 @@ var notoPrivateJSON []byte var NotoABI = solutils.MustParseBuildABI(notoPrivateJSON) type ConstructorParams struct { - Notary string `json:"notary"` // Lookup string for the notary identity - Implementation string `json:"implementation,omitempty"` // Use a specific implementation of Noto that was registered to the factory (blank to use default) - Hooks *HookParams `json:"hooks,omitempty"` // Configure hooks for programmable logic around Noto operations - RestrictMinting *bool `json:"restrictMinting,omitempty"` // Only allow notary to mint (default: true) - AllowBurning *bool `json:"allowBurning,omitempty"` // Allow token holders to burn their tokens (default: true) + Notary string `json:"notary"` // Lookup string for the notary identity + NotaryMode NotaryMode `json:"notaryMode"` // Notary mode (basic or hooks) + Implementation string `json:"implementation,omitempty"` // Use a specific implementation of Noto that was registered to the factory (blank to use default) + Options NotoOptions `json:"options"` // Configure options for the chosen notary mode } -// Currently the only supported hooks are provided via a Pente private smart contract -type HookParams struct { - PrivateGroup *PentePrivateGroup `json:"privateGroup,omitempty"` // Details on a Pente privacy group - PublicAddress *tktypes.EthAddress `json:"publicAddress,omitempty"` // Public address of the Pente privacy group - PrivateAddress *tktypes.EthAddress `json:"privateAddress,omitempty"` // Private address of the hook contract deployed within the privacy group +type NotaryMode string + +const ( + NotaryModeBasic NotaryMode = "basic" + NotaryModeHooks NotaryMode = "hooks" +) + +func (tt NotaryMode) Enum() tktypes.Enum[NotaryMode] { + return tktypes.Enum[NotaryMode](tt) +} + +func (tt NotaryMode) Options() []string { + return []string{ + string(NotaryModeBasic), + string(NotaryModeHooks), + } } type MintParams struct { @@ -68,6 +78,29 @@ type ApproveParams struct { Delegate *tktypes.EthAddress `json:"delegate"` } +type LockParams struct { + Amount *tktypes.HexUint256 `json:"amount"` + Data tktypes.HexBytes `json:"data"` +} + +type UnlockParams struct { + LockID tktypes.Bytes32 `json:"lockId"` + From string `json:"from"` + Recipients []*UnlockRecipient `json:"recipients"` + Data tktypes.HexBytes `json:"data"` +} + +type DelegateLockParams struct { + LockID tktypes.Bytes32 `json:"lockId"` + Delegate *tktypes.EthAddress `json:"delegate"` + Data tktypes.HexBytes `json:"data"` +} + +type UnlockRecipient struct { + To string `json:"to"` + Amount *tktypes.HexUint256 `json:"amount"` +} + type ApproveExtraParams struct { Data tktypes.HexBytes `json:"data"` } diff --git a/domains/noto/pkg/types/config.go b/domains/noto/pkg/types/config.go index caad2f6e9..a1d7ff930 100644 --- a/domains/noto/pkg/types/config.go +++ b/domains/noto/pkg/types/config.go @@ -27,32 +27,51 @@ type DomainConfig struct { var NotoConfigID_V0 = tktypes.MustParseHexBytes("0x00010000") +// This is the config we expect to receive from the contract registration event type NotoConfig_V0 struct { NotaryAddress tktypes.EthAddress `json:"notaryAddress"` Variant tktypes.HexUint64 `json:"variant"` Data tktypes.HexBytes `json:"data"` - DecodedData *NotoConfigData_V0 `json:"-"` } +// This is the structure we expect to unpack from the config data type NotoConfigData_V0 struct { - NotaryLookup string `json:"notaryLookup"` - NotaryType tktypes.HexUint64 `json:"notaryType"` - PrivateAddress *tktypes.EthAddress `json:"privateAddress"` - PrivateGroup *PentePrivateGroup `json:"privateGroup"` - RestrictMinting bool `json:"restrictMinting"` - AllowBurning bool `json:"allowBurning"` + NotaryLookup string `json:"notaryLookup"` + NotaryMode tktypes.HexUint64 `json:"notaryMode"` + PrivateAddress *tktypes.EthAddress `json:"privateAddress"` + PrivateGroup *PentePrivateGroup `json:"privateGroup"` + RestrictMint bool `json:"restrictMint"` + AllowBurn bool `json:"allowBurn"` + AllowLock bool `json:"allowLock"` + RestrictUnlock bool `json:"restrictUnlock"` } // This is the structure we parse the config into in InitConfig and gets passed back to us on every call type NotoParsedConfig struct { - NotaryLookup string `json:"notaryLookup"` - NotaryType tktypes.HexUint64 `json:"notaryType"` - NotaryAddress tktypes.EthAddress `json:"notaryAddress"` - Variant tktypes.HexUint64 `json:"variant"` - PrivateAddress *tktypes.EthAddress `json:"privateAddress,omitempty"` - PrivateGroup *PentePrivateGroup `json:"privateGroup,omitempty"` - RestrictMinting bool `json:"restrictMinting"` - AllowBurning bool `json:"allowBurning"` + NotaryLookup string `json:"notaryLookup"` + NotaryMode tktypes.Enum[NotaryMode] `json:"notaryMode"` + Variant tktypes.HexUint64 `json:"variant"` + IsNotary bool `json:"isNotary"` + Options NotoOptions `json:"options"` +} + +type NotoOptions struct { + Basic *NotoBasicOptions `json:"basic,omitempty"` + Hooks *NotoHooksOptions `json:"hooks,omitempty"` +} + +type NotoBasicOptions struct { + RestrictMint *bool `json:"restrictMint"` // Only allow notary to mint (default: true) + AllowBurn *bool `json:"allowBurn"` // Allow token holders to burn their tokens (default: true) + AllowLock *bool `json:"allowLock"` // Allow token holders to lock their tokens (default: true) + RestrictUnlock *bool `json:"restrictUnlock"` // Only allow lock creator to unlock tokens (default: true) +} + +type NotoHooksOptions struct { + PublicAddress *tktypes.EthAddress `json:"publicAddress"` // Public address of the Pente privacy group + PrivateGroup *PentePrivateGroup `json:"privateGroup,omitempty"` // Details on the Pente privacy group + PrivateAddress *tktypes.EthAddress `json:"privateAddress,omitempty"` // Private address of the hook contract deployed within the privacy group + DevUsePublicHooks bool `json:"devUsePublicHooks,omitempty"` // Use a public hooks contract - insecure, for dev purposes only! (privateGroup/privateAddress are ignored) } type PentePrivateGroup struct { @@ -81,8 +100,9 @@ var NotoTransactionDataABI_V0 = &abi.ParameterArray{ type DomainHandler = domain.DomainHandler[NotoParsedConfig] type ParsedTransaction = domain.ParsedTransaction[NotoParsedConfig] -var NotaryTypeSigner tktypes.HexUint64 = 0x0000 -var NotaryTypePente tktypes.HexUint64 = 0x0001 +const ( + NotaryModeIntBasic tktypes.HexUint64 = 0x0000 + NotaryModeIntHooks tktypes.HexUint64 = 0x0001 +) var NotoVariantDefault tktypes.HexUint64 = 0x0000 -var NotoVariantSelfSubmit tktypes.HexUint64 = 0x0001 diff --git a/domains/noto/pkg/types/states.go b/domains/noto/pkg/types/states.go index 3efa04c9f..de886b690 100644 --- a/domains/noto/pkg/types/states.go +++ b/domains/noto/pkg/types/states.go @@ -20,6 +20,34 @@ import ( "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" ) +type NotoDomainReceipt struct { + States ReceiptStates `json:"states"` + LockInfo *ReceiptLockInfo `json:"lockInfo,omitempty"` + Data tktypes.HexBytes `json:"data,omitempty"` +} + +type ReceiptStates struct { + Inputs []*ReceiptState `json:"inputs,omitempty"` + LockedInputs []*ReceiptState `json:"lockedInputs,omitempty"` + Outputs []*ReceiptState `json:"outputs,omitempty"` + LockedOutputs []*ReceiptState `json:"lockedOutputs,omitempty"` + ReadInputs []*ReceiptState `json:"readInputs,omitempty"` + ReadLockedInputs []*ReceiptState `json:"readLockedInputs,omitempty"` + PreparedOutputs []*ReceiptState `json:"preparedOutputs,omitempty"` + PreparedLockedOutputs []*ReceiptState `json:"preparedLockedOutputs,omitempty"` +} + +type ReceiptLockInfo struct { + LockID tktypes.Bytes32 `json:"lockId"` + Delegate *tktypes.EthAddress `json:"delegate,omitempty"` // only set for delegateLock + Unlock tktypes.HexBytes `json:"unlock,omitempty"` // only set for prepareUnlock +} + +type ReceiptState struct { + ID tktypes.HexBytes `json:"id"` + Data tktypes.RawJSON `json:"data"` +} + type NotoCoinState struct { ID tktypes.Bytes32 `json:"id"` Created tktypes.Timestamp `json:"created"` @@ -28,7 +56,7 @@ type NotoCoinState struct { } type NotoCoin struct { - Salt string `json:"salt"` + Salt tktypes.Bytes32 `json:"salt"` Owner *tktypes.EthAddress `json:"owner"` Amount *tktypes.HexUint256 `json:"amount"` } @@ -43,6 +71,42 @@ var NotoCoinABI = &abi.Parameter{ }, } +type NotoLockedCoin struct { + Salt tktypes.Bytes32 `json:"salt"` + LockID tktypes.Bytes32 `json:"lockId"` + Owner *tktypes.EthAddress `json:"owner"` + Amount *tktypes.HexUint256 `json:"amount"` +} + +var NotoLockedCoinABI = &abi.Parameter{ + Type: "tuple", + InternalType: "struct NotoLockedCoin", + Components: abi.ParameterArray{ + {Name: "salt", Type: "bytes32"}, + {Name: "lockId", Type: "bytes32", Indexed: true}, + {Name: "owner", Type: "string", Indexed: true}, + {Name: "amount", Type: "uint256"}, + }, +} + +type NotoLockInfo struct { + Salt tktypes.Bytes32 `json:"salt"` + LockID tktypes.Bytes32 `json:"lockId"` + Owner *tktypes.EthAddress `json:"owner"` + Delegate *tktypes.EthAddress `json:"delegate"` +} + +var NotoLockInfoABI = &abi.Parameter{ + Type: "tuple", + InternalType: "struct NotoLockInfo", + Components: abi.ParameterArray{ + {Name: "salt", Type: "bytes32"}, + {Name: "lockId", Type: "bytes32"}, + {Name: "owner", Type: "address"}, + {Name: "delegate", Type: "address"}, + }, +} + type TransactionData struct { Salt string `json:"salt"` Data tktypes.HexBytes `json:"data"` diff --git a/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/BondTest.java b/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/BondTest.java index 42b156a13..276d34a87 100644 --- a/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/BondTest.java +++ b/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/BondTest.java @@ -118,16 +118,13 @@ void testBond() throws Exception { alice, Algorithms.ECDSA_SECP256K1, Verifiers.ETH_ADDRESS); var mapper = new ObjectMapper(); - List notoSchemas = testbed.getRpcClient().request("pstate_listSchemas", - "noto"); - assertEquals(2, notoSchemas.size()); + + List> notoSchemas = testbed.getRpcClient().request("pstate_listSchemas", "noto"); StateSchema notoSchema = null; - for (var i = 0; i < 2; i++) { - var schema = mapper.convertValue(notoSchemas.get(i), StateSchema.class); - if (schema.signature().equals("type=NotoCoin(bytes32 salt,string owner,uint256 amount),labels=[owner,amount]")) { + for (var schemaJson : notoSchemas) { + var schema = mapper.convertValue(schemaJson, StateSchema.class); + if (schema.signature().startsWith("type=NotoCoin")) { notoSchema = schema; - } else { - assertEquals("type=TransactionData(bytes32 salt,bytes data),labels=[]", schema.signature()); } } assertNotNull(notoSchema); @@ -175,8 +172,8 @@ void testBond() throws Exception { var notoCash = NotoHelper.deploy("noto", cashIssuer, testbed, new NotoHelper.ConstructorParams( cashIssuer + "@node1", - null, - true)); + "basic", + null)); assertFalse(notoCash.address().isBlank()); // Create the public bond tracker on the base ledger (controlled by the privacy group) @@ -204,11 +201,12 @@ void testBond() throws Exception { var notoBond = NotoHelper.deploy("noto", bondCustodian, testbed, new NotoHelper.ConstructorParams( bondCustodian + "@node1", - new NotoHelper.HookParams( - issuerCustodianInstance.address(), - bondTracker.address(), - issuerCustodianGroup), - false)); + "hooks", + new NotoHelper.OptionsParams( + new NotoHelper.HookParams( + issuerCustodianInstance.address(), + bondTracker.address(), + issuerCustodianGroup)))); assertFalse(notoBond.address().isBlank()); // Issue cash to investors diff --git a/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/DomainIntegrationTests.java b/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/DomainIntegrationTests.java index df0d1bd05..989c6b0de 100644 --- a/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/DomainIntegrationTests.java +++ b/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/DomainIntegrationTests.java @@ -29,8 +29,7 @@ import java.util.LinkedHashMap; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.*; public class DomainIntegrationTests { @@ -85,6 +84,15 @@ JsonHex.Address deployNotoFactory() throws Exception { record NotoConstructorParamsJSON( @JsonProperty String notary, + @JsonProperty + String notaryMode, + @JsonProperty + NotoOptionsJSON options + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record NotoOptionsJSON( @JsonProperty NotoHookParamsJSON hooks ) { @@ -209,18 +217,16 @@ void testNotoPente() throws Exception { ); var mapper = new ObjectMapper(); - List notoSchemas = testbed.getRpcClient().request("pstate_listSchemas", - "noto"); - assertEquals(2, notoSchemas.size()); + + List> notoSchemas = testbed.getRpcClient().request("pstate_listSchemas", "noto"); StateSchema notoSchema = null; - for (var i = 0; i < 2; i++) { - var schema = mapper.convertValue(notoSchemas.get(i), StateSchema.class); - if (schema.signature().equals("type=NotoCoin(bytes32 salt,string owner,uint256 amount),labels=[owner,amount]")) { + for (var schemaJson : notoSchemas) { + var schema = mapper.convertValue(schemaJson, StateSchema.class); + if (schema.signature().startsWith("type=NotoCoin")) { notoSchema = schema; - } else { - assertEquals("type=TransactionData(bytes32 salt,bytes data),labels=[]", schema.signature()); } } + assertNotNull(notoSchema); // Create the privacy group String penteInstanceAddress = testbed.getRpcClient().request("testbed_deploy", @@ -265,10 +271,12 @@ void testNotoPente() throws Exception { "noto", "notary", new NotoConstructorParamsJSON( "notary@node1", - new NotoHookParamsJSON( - penteInstanceAddress, - notoTrackerAddress, - groupInfo))); + "hooks", + new NotoOptionsJSON( + new NotoHookParamsJSON( + penteInstanceAddress, + notoTrackerAddress, + groupInfo)))); assertFalse(notoInstanceAddress.isBlank()); // Perform Noto mint diff --git a/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/helpers/NotoHelper.java b/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/helpers/NotoHelper.java index f77b1f62c..9a7212cc8 100644 --- a/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/helpers/NotoHelper.java +++ b/domains/pente/src/test/java/io/kaleido/paladin/pente/domain/helpers/NotoHelper.java @@ -39,9 +39,16 @@ public record ConstructorParams( @JsonProperty String notary, @JsonProperty - HookParams hooks, + String notaryMode, @JsonProperty - boolean restrictMinting + OptionsParams options + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record OptionsParams( + @JsonProperty + HookParams hooks ) { } diff --git a/domains/zeto/internal/zeto/handler_deposit_test.go b/domains/zeto/internal/zeto/handler_deposit_test.go index 00238b809..1f8eda812 100644 --- a/domains/zeto/internal/zeto/handler_deposit_test.go +++ b/domains/zeto/internal/zeto/handler_deposit_test.go @@ -9,6 +9,7 @@ import ( corepb "github.com/kaleido-io/paladin/domains/zeto/pkg/proto" "github.com/kaleido-io/paladin/domains/zeto/pkg/types" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner/zetosignerapi" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -102,8 +103,8 @@ func TestDepositAssemble(t *testing.T) { Algorithm: h.zeto.getAlgoZetoSnarkBJJ(), VerifierType: zetosignerapi.IDEN3_PUBKEY_BABYJUBJUB_COMPRESSED_0X, }) - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") }, } diff --git a/domains/zeto/internal/zeto/handler_lock_test.go b/domains/zeto/internal/zeto/handler_lock_test.go index 9af067c1e..e8e6176e9 100644 --- a/domains/zeto/internal/zeto/handler_lock_test.go +++ b/domains/zeto/internal/zeto/handler_lock_test.go @@ -23,6 +23,7 @@ import ( corepb "github.com/kaleido-io/paladin/domains/zeto/pkg/proto" "github.com/kaleido-io/paladin/domains/zeto/pkg/types" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner/zetosignerapi" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -118,8 +119,8 @@ func TestLocktInit(t *testing.T) { func TestLockAssemble(t *testing.T) { params := sampleTransferPayload() - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { diff --git a/domains/zeto/internal/zeto/handler_transfer_test.go b/domains/zeto/internal/zeto/handler_transfer_test.go index b730acf35..745da8e73 100644 --- a/domains/zeto/internal/zeto/handler_transfer_test.go +++ b/domains/zeto/internal/zeto/handler_transfer_test.go @@ -26,6 +26,7 @@ import ( corepb "github.com/kaleido-io/paladin/domains/zeto/pkg/proto" "github.com/kaleido-io/paladin/domains/zeto/pkg/types" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner/zetosignerapi" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -148,8 +149,8 @@ func TestTransferAssemble(t *testing.T) { Algorithm: h.zeto.getAlgoZetoSnarkBJJ(), VerifierType: zetosignerapi.IDEN3_PUBKEY_BABYJUBJUB_COMPRESSED_0X, }) - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") }, } @@ -158,7 +159,7 @@ func TestTransferAssemble(t *testing.T) { assert.EqualError(t, err, "PD210039: Failed to prepare transaction inputs. PD210032: Failed to query the state store for available coins. test error") calls := 0 - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { defer func() { calls++ }() if calls == 0 { return &prototk.FindAvailableStatesResponse{ @@ -185,7 +186,7 @@ func TestTransferAssemble(t *testing.T) { _, err = h.Assemble(ctx, tx, req) assert.EqualError(t, err, "PD210039: Failed to prepare transaction inputs. PD210032: Failed to query the state store for available coins. test error") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -214,7 +215,7 @@ func TestTransferAssemble(t *testing.T) { assert.Equal(t, "0x7cdd539f3ed6c283494f47d8481f84308a6d7043087fb6711c9f1df04e2b8025", coin2.Owner.String()) assert.Equal(t, "0x06", coin2.Amount.String()) - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -237,7 +238,7 @@ func TestTransferAssemble(t *testing.T) { tx.DomainConfig.TokenName = constants.TOKEN_ANON_NULLIFIER tx.DomainConfig.CircuitId = constants.CIRCUIT_ANON_NULLIFIER called := 0 - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { var dataJson string if called == 0 { dataJson = "{\"salt\":\"0x13de02d64a5736a56b2d35d2a83dd60397ba70aae6f8347629f0960d4fee5d58\",\"owner\":\"0xc1d218cf8993f940e75eabd3fee23dadc4e89cd1de479f03a61e91727959281b\",\"amount\":\"0x0a\"}" @@ -388,8 +389,8 @@ func TestTransferPrepare(t *testing.T) { } func TestGenerateMerkleProofs(t *testing.T) { - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") }, } @@ -422,7 +423,7 @@ func TestGenerateMerkleProofs(t *testing.T) { _, _, err = generateMerkleProofs(ctx, h.zeto, "Zeto_Anon", queryContext, addr, inputCoins) assert.EqualError(t, err, "PD210019: Failed to create Merkle tree for smt_Zeto_Anon_0x1234567890123456789012345678901234567890: PD210065: Failed to find available states for the merkle tree. test error") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -440,7 +441,7 @@ func TestGenerateMerkleProofs(t *testing.T) { inputCoins[0].Salt = tktypes.MustParseHexUint256("0x042fac32983b19d76425cc54dd80e8a198f5d477c6a327cb286eb81a0c2b95ec") calls := 0 - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { defer func() { calls++ }() if calls == 0 { return &prototk.FindAvailableStatesResponse{ @@ -459,7 +460,7 @@ func TestGenerateMerkleProofs(t *testing.T) { _, _, err = generateMerkleProofs(ctx, h.zeto, "Zeto_Anon", queryContext, addr, inputCoins) assert.EqualError(t, err, "PD210055: Failed to query the smt DB for leaf node (ref=789c99b9a2196addb3ac11567135877e8b86bc9b5f7725808a79757fd36b2a2a). key not found") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { defer func() { calls++ }() if calls == 0 { return &prototk.FindAvailableStatesResponse{ @@ -482,7 +483,7 @@ func TestGenerateMerkleProofs(t *testing.T) { _, _, err = generateMerkleProofs(ctx, h.zeto, "Zeto_Anon", queryContext, addr, inputCoins) assert.EqualError(t, err, "PD210057: Coin (ref=789c99b9a2196addb3ac11567135877e8b86bc9b5f7725808a79757fd36b2a2a) found in the merkle tree but the persisted hash 26e3879b46b15a4ddbaca5d96af1bd2743f67f13f0bb85c40782950a2a700138 (index=3801702a0a958207c485bbf0137ff64327bdf16ad9a5acdb4d5ab1469b87e326) did not match the expected hash 0x303eb034d22aacc5dff09647928d757017a35e64e696d48609a250a6505e5d5f (index=5f5d5e50a650a20986d496e6645ea31770758d924796f0dfc5ac2ad234b03e30)") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { defer func() { calls++ }() if calls == 0 { return &prototk.FindAvailableStatesResponse{ @@ -505,22 +506,3 @@ func TestGenerateMerkleProofs(t *testing.T) { _, _, err = generateMerkleProofs(ctx, h.zeto, "Zeto_Anon", queryContext, addr, inputCoins) assert.NoError(t, err) } - -type testDomainCallbacks struct { - returnFunc func() (*prototk.FindAvailableStatesResponse, error) -} - -func (dc *testDomainCallbacks) FindAvailableStates(ctx context.Context, req *prototk.FindAvailableStatesRequest) (*prototk.FindAvailableStatesResponse, error) { - return dc.returnFunc() -} - -func (dc *testDomainCallbacks) EncodeData(ctx context.Context, req *prototk.EncodeDataRequest) (*prototk.EncodeDataResponse, error) { - return nil, nil -} -func (dc *testDomainCallbacks) RecoverSigner(ctx context.Context, req *prototk.RecoverSignerRequest) (*prototk.RecoverSignerResponse, error) { - return nil, nil -} - -func (dc *testDomainCallbacks) DecodeData(context.Context, *prototk.DecodeDataRequest) (*prototk.DecodeDataResponse, error) { - return nil, nil -} diff --git a/domains/zeto/internal/zeto/handler_withdraw_test.go b/domains/zeto/internal/zeto/handler_withdraw_test.go index 4ff81e9b8..720b2e944 100644 --- a/domains/zeto/internal/zeto/handler_withdraw_test.go +++ b/domains/zeto/internal/zeto/handler_withdraw_test.go @@ -9,6 +9,7 @@ import ( corepb "github.com/kaleido-io/paladin/domains/zeto/pkg/proto" "github.com/kaleido-io/paladin/domains/zeto/pkg/types" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner/zetosignerapi" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -102,8 +103,8 @@ func TestWithdrawAssemble(t *testing.T) { Algorithm: h.zeto.getAlgoZetoSnarkBJJ(), VerifierType: zetosignerapi.IDEN3_PUBKEY_BABYJUBJUB_COMPRESSED_0X, }) - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") }, } @@ -111,8 +112,8 @@ func TestWithdrawAssemble(t *testing.T) { _, err = h.Assemble(ctx, tx, req) assert.ErrorContains(t, err, "PD210039: Failed to prepare transaction inputs. PD210032: Failed to query the state store for available coins. test error") - h.zeto.Callbacks = &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + h.zeto.Callbacks = &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -134,8 +135,8 @@ func TestWithdrawAssemble(t *testing.T) { _, err = h.Assemble(ctx, tx, req) assert.ErrorContains(t, err, "PD210042: Failed to format proving request.") - h.zeto.Callbacks = &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + h.zeto.Callbacks = &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -152,8 +153,8 @@ func TestWithdrawAssemble(t *testing.T) { tx.DomainConfig.TokenName = constants.TOKEN_ANON_NULLIFIER tx.DomainConfig.CircuitId = constants.CIRCUIT_ANON_NULLIFIER called := 0 - h.zeto.Callbacks = &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + h.zeto.Callbacks = &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { var dataJson string if called == 0 { dataJson = "{\"salt\":\"0x13de02d64a5736a56b2d35d2a83dd60397ba70aae6f8347629f0960d4fee5d58\",\"owner\":\"0xc1d218cf8993f940e75eabd3fee23dadc4e89cd1de479f03a61e91727959281b\",\"amount\":\"0x65\"}" @@ -176,8 +177,8 @@ func TestWithdrawAssemble(t *testing.T) { require.ErrorContains(t, err, "PD210042: Failed to format proving request. PD210052: Failed to generate merkle proofs.") called = 0 - h.zeto.Callbacks = &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + h.zeto.Callbacks = &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { var dataJson string if called == 0 { dataJson = "{\"salt\":\"0x13de02d64a5736a56b2d35d2a83dd60397ba70aae6f8347629f0960d4fee5d58\",\"owner\":\"0xc1d218cf8993f940e75eabd3fee23dadc4e89cd1de479f03a61e91727959281b\",\"amount\":\"0x65\"}" diff --git a/domains/zeto/internal/zeto/smt/storage_test.go b/domains/zeto/internal/zeto/smt/storage_test.go index 03f895d42..5851a2ff2 100644 --- a/domains/zeto/internal/zeto/smt/storage_test.go +++ b/domains/zeto/internal/zeto/smt/storage_test.go @@ -16,7 +16,6 @@ package smt import ( - "context" "encoding/json" "errors" "math/big" @@ -24,30 +23,12 @@ import ( "github.com/hyperledger-labs/zeto/go-sdk/pkg/sparse-merkle-tree/core" "github.com/hyperledger-labs/zeto/go-sdk/pkg/sparse-merkle-tree/node" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" ) -type testDomainCallbacks struct { - returnFunc func() (*prototk.FindAvailableStatesResponse, error) -} - -func (dc *testDomainCallbacks) FindAvailableStates(ctx context.Context, req *prototk.FindAvailableStatesRequest) (*prototk.FindAvailableStatesResponse, error) { - return dc.returnFunc() -} - -func (dc *testDomainCallbacks) EncodeData(ctx context.Context, req *prototk.EncodeDataRequest) (*prototk.EncodeDataResponse, error) { - return nil, nil -} -func (dc *testDomainCallbacks) RecoverSigner(ctx context.Context, req *prototk.RecoverSignerRequest) (*prototk.RecoverSignerResponse, error) { - return nil, nil -} - -func (dc *testDomainCallbacks) DecodeData(context.Context, *prototk.DecodeDataRequest) (*prototk.DecodeDataResponse, error) { - return nil, nil -} - func returnCustomError() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") } @@ -130,13 +111,13 @@ func returnNode(t int) func() (*prototk.FindAvailableStatesResponse, error) { func TestStorage(t *testing.T) { stateQueryConext := tktypes.ShortID() - storage := NewStatesStorage(&testDomainCallbacks{returnFunc: returnCustomError}, "test", stateQueryConext, "root-schema", "node-schema") + storage := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnCustomError}, "test", stateQueryConext, "root-schema", "node-schema") smt, err := NewSmt(storage) assert.EqualError(t, err, "PD210065: Failed to find available states for the merkle tree. test error") assert.NotNil(t, storage) assert.Nil(t, smt) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") smt, err = NewSmt(storage) assert.NoError(t, err) assert.NotNil(t, storage) @@ -150,13 +131,13 @@ func TestStorage(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "0000000000000000000000000000000000000000000000000000000000000000", idx.Hex()) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnBadData}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnBadData}, "test", stateQueryConext, "root-schema", "node-schema") smt, err = NewSmt(storage) assert.EqualError(t, err, "PD210066: Failed to unmarshal root node index. invalid character 'b' looking for beginning of value") assert.NotNil(t, storage) assert.Nil(t, smt) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(0)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(0)}, "test", stateQueryConext, "root-schema", "node-schema") smt, err = NewSmt(storage) assert.NoError(t, err) assert.NotNil(t, storage) @@ -185,7 +166,7 @@ func TestStorage(t *testing.T) { func TestUpsertRootNodeIndex(t *testing.T) { stateQueryConext := tktypes.ShortID() - storage := NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") _, _ = NewSmt(storage) assert.NotNil(t, storage) tx, err := storage.BeginTx() @@ -208,46 +189,46 @@ func TestGetNode(t *testing.T) { stateQueryConext := tktypes.ShortID() idx, _ := node.NewNodeIndexFromBigInt(big.NewInt(1234)) - storage := NewStatesStorage(&testDomainCallbacks{returnFunc: returnCustomError}, "test", stateQueryConext, "root-schema", "node-schema") + storage := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnCustomError}, "test", stateQueryConext, "root-schema", "node-schema") _, err := storage.GetNode(idx) assert.EqualError(t, err, "PD210065: Failed to find available states for the merkle tree. test error") - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") _, err = storage.GetNode(idx) assert.EqualError(t, err, core.ErrNotFound.Error()) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(1)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(1)}, "test", stateQueryConext, "root-schema", "node-schema") n, err := storage.GetNode(idx) assert.NoError(t, err) assert.NotNil(t, n) assert.Equal(t, "197b0dc3f167041e03d3eafacec1aa3ab12a0d7a606581af01447c269935e521", n.Index().Hex()) assert.Equal(t, core.NodeTypeLeaf, n.Type()) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(2)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(2)}, "test", stateQueryConext, "root-schema", "node-schema") n, err = storage.GetNode(idx) assert.NoError(t, err) assert.NotNil(t, n) assert.Empty(t, n.Index()) assert.Equal(t, "197b0dc3f167041e03d3eafacec1aa3ab12a0d7a606581af01447c269935e521", n.LeftChild().Hex()) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(3)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(3)}, "test", stateQueryConext, "root-schema", "node-schema") _, err = storage.GetNode(idx) assert.EqualError(t, err, "inputs values not inside Finite Field") - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(4)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(4)}, "test", stateQueryConext, "root-schema", "node-schema") _, err = storage.GetNode(idx) assert.EqualError(t, err, "inputs values not inside Finite Field") - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(5)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(5)}, "test", stateQueryConext, "root-schema", "node-schema") _, err = storage.GetNode(idx) assert.ErrorContains(t, err, "PD210067: Failed to unmarshal Merkle Tree Node from state json. PD020007: Invalid hex") - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnNode(6)}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnNode(6)}, "test", stateQueryConext, "root-schema", "node-schema") _, err = storage.GetNode(idx) assert.ErrorContains(t, err, "PD210067: Failed to unmarshal Merkle Tree Node from state json. PD020008: Failed to parse value as 32 byte hex string") // test with committed nodes - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") tx1, err := storage.BeginTx() assert.NoError(t, err) n1, _ := node.NewLeafNode(node.NewIndexOnly(idx)) @@ -259,7 +240,7 @@ func TestGetNode(t *testing.T) { assert.Equal(t, n1, n2) // test with pending nodes (called when we are still updating a leaf node path up to the root) - storage = NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage = NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") tx2, err := storage.BeginTx() assert.NoError(t, err) n3, _ := node.NewLeafNode(node.NewIndexOnly(idx)) @@ -272,7 +253,7 @@ func TestGetNode(t *testing.T) { func TestInsertNode(t *testing.T) { stateQueryConext := tktypes.ShortID() - storage := NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") assert.NotNil(t, storage) idx, _ := node.NewNodeIndexFromBigInt(big.NewInt(1234)) n, _ := node.NewLeafNode(node.NewIndexOnly(idx)) @@ -313,7 +294,7 @@ func TestInsertNode(t *testing.T) { func TestUnimplementedMethods(t *testing.T) { stateQueryConext := tktypes.ShortID() - storage := NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") + storage := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", stateQueryConext, "root-schema", "node-schema") assert.NotNil(t, storage) storage.(*statesStorage).Close() } @@ -334,13 +315,13 @@ func TestNodesTxGetNode(t *testing.T) { } func TestSetTransactionId(t *testing.T) { - storage := NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", "stateQueryContext", "root-schema", "node-schema") + storage := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", "stateQueryContext", "root-schema", "node-schema") storage.SetTransactionId("txid") assert.Equal(t, "txid", storage.(*statesStorage).pendingNodesTx.transactionId) } func TestGetNewStates(t *testing.T) { - s := NewStatesStorage(&testDomainCallbacks{returnFunc: returnEmptyStates}, "test", "stateQueryContext", "root-schema", "node-schema") + s := NewStatesStorage(&domain.MockDomainCallbacks{MockFindAvailableStates: returnEmptyStates}, "test", "stateQueryContext", "root-schema", "node-schema") storage := s.(*statesStorage) states, err := storage.GetNewStates() assert.NoError(t, err) diff --git a/domains/zeto/internal/zeto/states_test.go b/domains/zeto/internal/zeto/states_test.go index 7703706fb..6bf7d61e2 100644 --- a/domains/zeto/internal/zeto/states_test.go +++ b/domains/zeto/internal/zeto/states_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/kaleido-io/paladin/domains/zeto/pkg/types" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -33,8 +34,8 @@ func TestGetStateSchemas(t *testing.T) { } func TestPrepareInputs(t *testing.T) { - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") }, } @@ -57,13 +58,13 @@ func TestPrepareInputs(t *testing.T) { _, _, _, _, err := zeto.prepareInputsForTransfer(ctx, false, stateQueryContext, "Alice", []*types.TransferParamEntry{{Amount: tktypes.Uint64ToUint256(100)}}) assert.EqualError(t, err, "PD210032: Failed to query the state store for available coins. test error") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{}, nil } _, _, _, _, err = zeto.prepareInputsForTransfer(ctx, false, stateQueryContext, "Alice", []*types.TransferParamEntry{{Amount: tktypes.Uint64ToUint256(100)}}) assert.EqualError(t, err, "PD210033: Insufficient funds (available=0)") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -76,7 +77,7 @@ func TestPrepareInputs(t *testing.T) { _, _, _, _, err = zeto.prepareInputsForTransfer(ctx, false, stateQueryContext, "Alice", []*types.TransferParamEntry{{Amount: tktypes.Uint64ToUint256(100)}}) assert.EqualError(t, err, "PD210034: Coin state-1 is invalid: invalid character 'b' looking for beginning of value") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ {Id: "state-1", DataJson: "{\"amount\": \"10\"}"}, @@ -100,8 +101,8 @@ func TestPrepareInputs(t *testing.T) { } func TestPrepareOutputs(t *testing.T) { - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("test error") }, } diff --git a/domains/zeto/internal/zeto/zeto_test.go b/domains/zeto/internal/zeto/zeto_test.go index b439dcf64..14002d928 100644 --- a/domains/zeto/internal/zeto/zeto_test.go +++ b/domains/zeto/internal/zeto/zeto_test.go @@ -31,6 +31,7 @@ import ( "github.com/kaleido-io/paladin/domains/zeto/pkg/types" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner" "github.com/kaleido-io/paladin/domains/zeto/pkg/zetosigner/zetosignerapi" + "github.com/kaleido-io/paladin/toolkit/pkg/domain" "github.com/kaleido-io/paladin/toolkit/pkg/prototk" "github.com/kaleido-io/paladin/toolkit/pkg/tktypes" "github.com/stretchr/testify/assert" @@ -39,7 +40,7 @@ import ( ) func TestNew(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) assert.NotNil(t, z) } @@ -87,7 +88,7 @@ func TestDecodeDomainConfig(t *testing.T) { } func TestInitDomain(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) req := &prototk.InitDomainRequest{ AbiStateSchemas: []*prototk.StateSchema{ @@ -111,7 +112,7 @@ func TestInitDomain(t *testing.T) { } func TestInitDeploy(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) req := &prototk.InitDeployRequest{ Transaction: &prototk.DeployTransactionSpecification{ @@ -128,7 +129,7 @@ func TestInitDeploy(t *testing.T) { } func TestPrepareDeploy(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) z.config = &types.DomainFactoryConfig{ DomainContracts: types.DomainConfigContracts{ @@ -164,7 +165,7 @@ func TestPrepareDeploy(t *testing.T) { } func TestInitContract(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) req := &prototk.InitContractRequest{ ContractConfig: []byte("bad config"), @@ -191,7 +192,7 @@ func TestInitContract(t *testing.T) { } func TestInitTransaction(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) req := &prototk.InitTransactionRequest{ Transaction: &prototk.TransactionSpecification{ @@ -259,7 +260,7 @@ func TestInitTransaction(t *testing.T) { } func TestAssembleTransaction(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) z.name = "z1" z.coinSchema = &prototk.StateSchema{ @@ -301,7 +302,7 @@ func TestAssembleTransaction(t *testing.T) { } func TestEndorseTransaction(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) req := &prototk.EndorseTransactionRequest{ Transaction: &prototk.TransactionSpecification{ @@ -327,7 +328,7 @@ func TestEndorseTransaction(t *testing.T) { } func TestPrepareTransaction(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) z.config = &types.DomainFactoryConfig{ DomainContracts: types.DomainConfigContracts{ @@ -368,8 +369,8 @@ func TestPrepareTransaction(t *testing.T) { } func TestFindCoins(t *testing.T) { - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("find coins error") }, } @@ -383,7 +384,7 @@ func TestFindCoins(t *testing.T) { _, err := findCoins(context.Background(), z, useNullifiers, addr, "{}") assert.EqualError(t, err, "find coins error") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{ States: []*prototk.StoredState{ { @@ -397,9 +398,9 @@ func TestFindCoins(t *testing.T) { assert.NotNil(t, res) } -func newTestZeto() (*Zeto, *testDomainCallbacks) { - testCallbacks := &testDomainCallbacks{ - returnFunc: func() (*prototk.FindAvailableStatesResponse, error) { +func newTestZeto() (*Zeto, *domain.MockDomainCallbacks) { + testCallbacks := &domain.MockDomainCallbacks{ + MockFindAvailableStates: func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{}, nil }, } @@ -422,7 +423,7 @@ func newTestZeto() (*Zeto, *testDomainCallbacks) { func TestHandleEventBatch(t *testing.T) { z, testCallbacks := newTestZeto() - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return nil, errors.New("find merkle tree root error") } ctx := context.Background() @@ -452,7 +453,7 @@ func TestHandleEventBatch(t *testing.T) { _, err = z.HandleEventBatch(ctx, req) assert.EqualError(t, err, "PD210019: Failed to create Merkle tree for smt_Zeto_AnonNullifier_0x1234567890123456789012345678901234567890: PD210065: Failed to find available states for the merkle tree. find merkle tree root error") - testCallbacks.returnFunc = func() (*prototk.FindAvailableStatesResponse, error) { + testCallbacks.MockFindAvailableStates = func() (*prototk.FindAvailableStatesResponse, error) { return &prototk.FindAvailableStatesResponse{}, nil } res1, err := z.HandleEventBatch(ctx, req) @@ -487,7 +488,7 @@ func TestHandleEventBatch(t *testing.T) { } func TestGetVerifier(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) z.name = "z1" z.coinSchema = &prototk.StateSchema{ @@ -515,7 +516,7 @@ func TestGetVerifier(t *testing.T) { } func TestSign(t *testing.T) { - testCallbacks := &testDomainCallbacks{} + testCallbacks := &domain.MockDomainCallbacks{} z := New(testCallbacks) z.name = "z1" z.coinSchema = &prototk.StateSchema{ diff --git a/example/bond/src/index.ts b/example/bond/src/index.ts index da0b51032..18d4e2efc 100644 --- a/example/bond/src/index.ts +++ b/example/bond/src/index.ts @@ -1,16 +1,14 @@ import PaladinClient, { - Algorithms, encodeStates, newGroupSalt, newTransactionId, NotoFactory, PenteFactory, TransactionType, - Verifiers, } from "@lfdecentralizedtrust-labs/paladin-sdk"; -import bondTrackerPublicJson from "./abis/BondTrackerPublic.json"; -import atomFactoryJson from "./abis/AtomFactory.json"; import atomJson from "./abis/Atom.json"; +import atomFactoryJson from "./abis/AtomFactory.json"; +import bondTrackerPublicJson from "./abis/BondTrackerPublic.json"; import { newBondSubscription } from "./helpers/bondsubscription"; import { newBondTracker } from "./helpers/bondtracker"; import { checkDeploy, checkReceipt } from "./util"; @@ -40,7 +38,7 @@ async function main(): Promise { const notoFactory = new NotoFactory(paladin1, "noto"); const notoCash = await notoFactory.newNoto(cashIssuer, { notary: cashIssuer, - restrictMinting: true, + notaryMode: "basic", }); if (!checkDeploy(notoCash)) return false; @@ -107,12 +105,14 @@ async function main(): Promise { logger.log("Deploying Noto bond token..."); const notoBond = await notoFactory.newNoto(bondIssuer, { notary: bondCustodian, - hooks: { - privateGroup: issuerCustodianGroup.group, - publicAddress: issuerCustodianGroup.address, - privateAddress: bondTracker.address, + notaryMode: "hooks", + options: { + hooks: { + privateGroup: issuerCustodianGroup.group, + publicAddress: issuerCustodianGroup.address, + privateAddress: bondTracker.address, + }, }, - restrictMinting: false, }); if (!checkDeploy(notoBond)) return false; diff --git a/example/lock/.gitignore b/example/lock/.gitignore new file mode 100644 index 000000000..4c79bef56 --- /dev/null +++ b/example/lock/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +src/abis/*.json \ No newline at end of file diff --git a/example/lock/.vscode/launch.json b/example/lock/.vscode/launch.json new file mode 100644 index 000000000..1f1326095 --- /dev/null +++ b/example/lock/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run", + "runtimeExecutable": "npm", + "args": ["run", "start"], + "request": "launch", + "type": "node", + "outputCapture": "std" + } + ] + } \ No newline at end of file diff --git a/example/lock/build.gradle b/example/lock/build.gradle new file mode 100644 index 000000000..59e2e8f00 --- /dev/null +++ b/example/lock/build.gradle @@ -0,0 +1,73 @@ +/* + * Copyright © 2024 Kaleido, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +configurations { + // Resolvable configurations + contractCompile { + canBeConsumed = false + canBeResolved = true + } + buildSDK { + canBeConsumed = false + canBeResolved = true + } +} + +dependencies { + contractCompile project(path: ':solidity', configuration: 'compiledContracts') + buildSDK project(path: ':sdk:typescript', configuration: 'buildSDK') +} + +task install(type: Exec) { + executable 'npm' + args 'install' + + inputs.files(configurations.buildSDK) + inputs.files('package.json') + outputs.files('package-lock.json') + outputs.dir('node_modules') +} + +task copyABI(type: Exec, dependsOn: install) { + executable 'npm' + args 'run' + args 'abi' + + inputs.files(configurations.contractCompile) + inputs.dir('scripts') + outputs.dir('src/abis') +} + +task build(type: Exec, dependsOn: [install, copyABI]) { + executable 'npm' + args 'run' + args 'build' + + inputs.dir('src') + outputs.dir('build') +} + +task e2e(type: Exec, dependsOn: [build]) { + dependsOn ':operator:deploy' + + executable 'npm' + args 'run' + args 'start' +} + +task clean(type: Delete) { + delete 'node_modules' + delete 'build' +} diff --git a/example/lock/package-lock.json b/example/lock/package-lock.json new file mode 100644 index 000000000..69cee94a2 --- /dev/null +++ b/example/lock/package-lock.json @@ -0,0 +1,288 @@ +{ + "name": "paladin-example-bond", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paladin-example-bond", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@lfdecentralizedtrust-labs/paladin-sdk": "file:../../sdk/typescript" + }, + "devDependencies": { + "@types/node": "^22.8.7", + "copy-file": "^11.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + } + }, + "../../sdk/typescript": { + "name": "@lfdecentralizedtrust-labs/paladin-sdk", + "version": "0.0.6-alpha.2", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.7.7", + "ethers": "^6.13.4", + "uuid": "^11.0.2" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "copy-file": "^11.0.0", + "typescript": "^5.6.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lfdecentralizedtrust-labs/paladin-sdk": { + "resolved": "../../sdk/typescript", + "link": true + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.7.tgz", + "integrity": "sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/copy-file": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-file/-/copy-file-11.0.0.tgz", + "integrity": "sha512-mFsNh/DIANLqFt5VHZoGirdg7bK5+oTWlhnGu6tgRhzBlnEKWaPX2xrFaLltii/6rmhqFMJqffUgknuRdpYlHw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.11", + "p-event": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.3.tgz", + "integrity": "sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/example/lock/package.json b/example/lock/package.json new file mode 100644 index 000000000..651d23ce0 --- /dev/null +++ b/example/lock/package.json @@ -0,0 +1,23 @@ +{ + "name": "paladin-example-bond", + "version": "0.0.1", + "description": "", + "main": "build/index.js", + "scripts": { + "build": "tsc", + "start": "ts-node ./src/index.ts", + "start:prod": "node ./build/index.js", + "abi": "node scripts/abi.mjs" + }, + "author": "", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.8.7", + "copy-file": "^11.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + }, + "dependencies": { + "@lfdecentralizedtrust-labs/paladin-sdk": "file:../../sdk/typescript" + } +} diff --git a/example/lock/scripts/abi.mjs b/example/lock/scripts/abi.mjs new file mode 100644 index 000000000..03844613e --- /dev/null +++ b/example/lock/scripts/abi.mjs @@ -0,0 +1,6 @@ +import { copyFile } from "copy-file"; + +await copyFile( + "../../solidity/artifacts/contracts/private/NotoTrackerERC20.sol/NotoTrackerERC20.json", + "src/abis/NotoTrackerERC20.json" +); diff --git a/example/lock/src/helpers/atom.ts b/example/lock/src/helpers/atom.ts deleted file mode 100644 index f965dfbf3..000000000 --- a/example/lock/src/helpers/atom.ts +++ /dev/null @@ -1,73 +0,0 @@ -import PaladinClient, { - PaladinVerifier, - PentePrivacyGroup, - PentePrivateContract, - TransactionType, -} from "@lfdecentralizedtrust-labs/paladin-sdk"; -import atomFactoryJson from "../abis/AtomFactory.json"; - -export interface AtomOperation { - contractAddress: string; - callData: string; -} - -export const newAtomFactory = async ( - paladin: PaladinClient, - from: PaladinVerifier -) => { - const txID = await paladin.sendTransaction({ - type: TransactionType.PUBLIC, - abi: atomFactoryJson.abi, - bytecode: atomFactoryJson.bytecode, - function: "", - from: from.lookup, - data: {}, - }); - const receipt = await paladin.pollForReceipt(txID, 10000); - return receipt?.contractAddress - ? new AtomFactory(paladin, receipt.contractAddress) - : undefined; -}; - -export class AtomFactory { - constructor( - protected paladin: PaladinClient, - public readonly address: string - ) {} - - using(paladin: PaladinClient) { - return new AtomFactory(paladin, this.address); - } - - async create(from: PaladinVerifier, operations: AtomOperation[]) { - const txID = await this.paladin.sendTransaction({ - type: TransactionType.PUBLIC, - abi: atomFactoryJson.abi, - function: "create", - from: from.lookup, - to: this.address, - data: { operations }, - }); - const receipt = await this.paladin.pollForReceipt(txID, 10000); - if (receipt) { - const events = await this.paladin.decodeTransactionEvents( - receipt.transactionHash, - atomFactoryJson.abi, - "" - ); - const deployedEvent = events.find((ev) => - ev.soliditySignature.startsWith("event AtomDeployed") - ); - const atomAddress = deployedEvent?.data.addr; - return atomAddress ? new Atom(this.paladin, atomAddress) : undefined; - } - return undefined; - } -} - -export class Atom { - constructor( - protected paladin: PaladinClient, - public readonly address: string - ) {} -} diff --git a/example/lock/src/helpers/erc20tracker.ts b/example/lock/src/helpers/erc20tracker.ts new file mode 100644 index 000000000..8171613e1 --- /dev/null +++ b/example/lock/src/helpers/erc20tracker.ts @@ -0,0 +1,38 @@ +import PaladinClient, { + PaladinVerifier, + PentePrivacyGroup, + PentePrivateContract, +} from "@lfdecentralizedtrust-labs/paladin-sdk"; +import erc20Tracker from "../abis/NotoTrackerERC20.json"; + +export interface ERC20TrackerConstructorParams { + name: string; + symbol: string; +} + +export const newERC20Tracker = async ( + pente: PentePrivacyGroup, + from: PaladinVerifier, + params: ERC20TrackerConstructorParams +) => { + const address = await pente.deploy( + erc20Tracker.abi, + erc20Tracker.bytecode, + from, + params + ); + return address ? new BondTracker(pente, address) : undefined; +}; + +export class BondTracker extends PentePrivateContract { + constructor( + protected evm: PentePrivacyGroup, + public readonly address: string + ) { + super(evm, erc20Tracker.abi, address); + } + + using(paladin: PaladinClient) { + return new BondTracker(this.evm.using(paladin), this.address); + } +} diff --git a/example/lock/src/index.ts b/example/lock/src/index.ts new file mode 100644 index 000000000..840a75374 --- /dev/null +++ b/example/lock/src/index.ts @@ -0,0 +1,139 @@ +import PaladinClient, { + INotoDomainReceipt, + newGroupSalt, + NotoFactory, + PenteFactory, +} from "@lfdecentralizedtrust-labs/paladin-sdk"; +import { newERC20Tracker } from "./helpers/erc20tracker"; +import { checkDeploy, checkReceipt } from "./util"; +import { randomBytes } from "crypto"; + +const logger = console; + +const paladin1 = new PaladinClient({ + url: "http://127.0.0.1:31548", +}); +const paladin2 = new PaladinClient({ + url: "http://127.0.0.1:31648", +}); +const paladin3 = new PaladinClient({ + url: "http://127.0.0.1:31748", +}); + +async function main(): Promise { + const [cashIssuer] = paladin1.getVerifiers("cashIssuer@node1"); + const [investor1] = paladin2.getVerifiers("investor1@node2"); + const [investor2] = paladin3.getVerifiers("investor2@node3"); + + // Create a Pente privacy group for the issuer only + logger.log("Creating issuer privacy group..."); + const penteFactory = new PenteFactory(paladin1, "pente"); + const issuerGroup = await penteFactory.newPrivacyGroup(cashIssuer, { + group: { + salt: newGroupSalt(), + members: [cashIssuer], + }, + evmVersion: "shanghai", + endorsementType: "group_scoped_identities", + externalCallsEnabled: true, + }); + if (!checkDeploy(issuerGroup)) return false; + + // Deploy private tracker to the issuer privacy group + logger.log("Creating private tracker..."); + const tracker = await newERC20Tracker(issuerGroup, cashIssuer, { + name: "CASH", + symbol: "CASH", + }); + if (!checkDeploy(tracker)) return false; + + // Create a Noto token to represent cash + logger.log("Deploying Noto cash token..."); + const notoFactory = new NotoFactory(paladin1, "noto"); + const notoCash = await notoFactory.newNoto(cashIssuer, { + notary: cashIssuer, + notaryMode: "hooks", + options: { + hooks: { + privateGroup: issuerGroup.group, + publicAddress: issuerGroup.address, + privateAddress: tracker.address, + }, + }, + }); + if (!checkDeploy(notoCash)) return false; + + // Issue some cash + logger.log("Issuing cash to investor1..."); + let receipt = await notoCash.mint(cashIssuer, { + to: investor1, + amount: 1000, + data: "0x", + }); + if (!checkReceipt(receipt)) return false; + + // Lock some tokens + logger.log("Locking cash from investor1..."); + receipt = await notoCash.using(paladin2).lock(investor1, { + amount: 100, + data: "0x", + }); + if (!checkReceipt(receipt)) return false; + receipt = await paladin2.getTransactionReceipt(receipt.id, true); + + let domainReceipt = receipt?.domainReceipt as INotoDomainReceipt | undefined; + const lockId = domainReceipt?.lockInfo?.lockId; + if (lockId === undefined) { + logger.error("No lock ID found in domain receipt"); + return false; + } + + // Prepare unlock operation + logger.log("Preparing unlock to investor2..."); + receipt = await notoCash.using(paladin2).prepareUnlock(investor1, { + lockId, + from: investor1, + recipients: [{ to: investor2, amount: 100 }], + data: "0x", + }); + if (!checkReceipt(receipt)) return false; + receipt = await paladin2.getTransactionReceipt(receipt.id, true); + domainReceipt = receipt?.domainReceipt as INotoDomainReceipt | undefined; + + // Approve unlock operation + logger.log("Delegating lock to investor2..."); + receipt = await notoCash.using(paladin2).delegateLock(investor1, { + lockId, + delegate: await investor2.address(), + data: "0x", + }); + if (!checkReceipt(receipt)) return false; + + // Unlock the tokens + logger.log("Unlocking cash..."); + receipt = await notoCash.using(paladin3).unlockAsDelegate(investor2, { + lockId, + lockedInputs: + domainReceipt?.states.readLockedInputs?.map((s) => s.id) ?? [], + lockedOutputs: + domainReceipt?.states.preparedLockedOutputs?.map((s) => s.id) ?? [], + outputs: domainReceipt?.states.preparedOutputs?.map((s) => s.id) ?? [], + signature: "0x", + data: "0x", + }); + if (!checkReceipt(receipt)) return false; + + return true; +} + +if (require.main === module) { + main() + .then((success: boolean) => { + process.exit(success ? 0 : 1); + }) + .catch((err) => { + console.error("Exiting with uncaught error"); + console.error(err); + process.exit(1); + }); +} diff --git a/example/lock/src/util.ts b/example/lock/src/util.ts new file mode 100644 index 000000000..1379fed65 --- /dev/null +++ b/example/lock/src/util.ts @@ -0,0 +1,32 @@ +import { ITransactionReceipt } from "@lfdecentralizedtrust-labs/paladin-sdk"; + +const logger = console; + +export interface DeployedContract { + address: string; +} + +export function checkDeploy( + contract: DeployedContract | undefined +): contract is DeployedContract { + if (contract === undefined) { + logger.error("Failed!"); + return false; + } + logger.log(`Success! address: ${contract.address}`); + return true; +} + +export function checkReceipt( + receipt: ITransactionReceipt | undefined +): receipt is ITransactionReceipt { + if (receipt === undefined) { + logger.error("Failed!"); + return false; + } else if (receipt.failureMessage !== undefined) { + logger.error(`Failed: ${receipt.failureMessage}`); + return false; + } + logger.log("Success!"); + return true; +} diff --git a/example/lock/tsconfig.json b/example/lock/tsconfig.json new file mode 100644 index 000000000..854d27e37 --- /dev/null +++ b/example/lock/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "outDir": "./build", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/operator/test/e2e/e2e_noto_pente_test.go b/operator/test/e2e/e2e_noto_pente_test.go index b7623d7ff..646dca970 100644 --- a/operator/test/e2e/e2e_noto_pente_test.go +++ b/operator/test/e2e/e2e_noto_pente_test.go @@ -248,6 +248,7 @@ var _ = Describe("noto/pente - simple", Ordered, func() { deploy := rpc["node1"].ForABI(ctx, abi.ABI{ {Type: abi.Constructor, Inputs: abi.ParameterArray{ {Name: "notary", Type: "string"}, + {Name: "notaryMode", Type: "string"}, }}, }). Private(). @@ -255,7 +256,8 @@ var _ = Describe("noto/pente - simple", Ordered, func() { Constructor(). From(notary). Inputs(¬otypes.ConstructorParams{ - Notary: notary, + Notary: notary, + NotaryMode: nototypes.NotaryModeBasic, }). Send(). Wait(5 * time.Second) @@ -366,8 +368,8 @@ var _ = Describe("noto/pente - simple", Ordered, func() { }) penteGroupNodes1and2 := nototypes.PentePrivateGroup{ - Salt: tktypes.Bytes32(tktypes.RandBytes(32)), // unique salt must be shared privately to retain anonymity - Members: []string{"bob@node1", "sally@node2"}, // these will be salted to establish the endorsement key identifiers + Salt: tktypes.RandBytes32(), // unique salt must be shared privately to retain anonymity + Members: []string{"bob@node1", "sally@node2"}, // these will be salted to establish the endorsement key identifiers } var penteContract *tktypes.EthAddress @@ -609,10 +611,13 @@ var _ = Describe("noto/pente - simple", Ordered, func() { deploy := rpc["node1"].ForABI(ctx, abi.ABI{ {Type: abi.Constructor, Inputs: abi.ParameterArray{ {Name: "notary", Type: "string"}, - {Name: "hooks", Type: "tuple", Components: abi.ParameterArray{ - {Name: "publicAddress", Type: "string"}, - {Name: "privateAddress", Type: "string"}, - {Name: "privateGroup", Type: "tuple", Components: pentePrivGroupComps}, + {Name: "notaryMode", Type: "string"}, + {Name: "options", Type: "tuple", Components: abi.ParameterArray{ + {Name: "hooks", Type: "tuple", Components: abi.ParameterArray{ + {Name: "publicAddress", Type: "string"}, + {Name: "privateAddress", Type: "string"}, + {Name: "privateGroup", Type: "tuple", Components: pentePrivGroupComps}, + }}, }}, }}, }). @@ -621,11 +626,14 @@ var _ = Describe("noto/pente - simple", Ordered, func() { Constructor(). From(notary). Inputs(¬otypes.ConstructorParams{ - Notary: notary, - Hooks: ¬otypes.HookParams{ - PublicAddress: penteContract, - PrivateAddress: notoTrackerAddr, - PrivateGroup: &penteGroupNodes1and2, + Notary: notary, + NotaryMode: nototypes.NotaryModeHooks, + Options: nototypes.NotoOptions{ + Hooks: ¬otypes.NotoHooksOptions{ + PublicAddress: penteContract, + PrivateAddress: notoTrackerAddr, + PrivateGroup: &penteGroupNodes1and2, + }, }, }). Send(). diff --git a/operator/test/e2e/e2e_pente_parallel_test.go b/operator/test/e2e/e2e_pente_parallel_test.go index 1222bddcf..3793ea902 100644 --- a/operator/test/e2e/e2e_pente_parallel_test.go +++ b/operator/test/e2e/e2e_pente_parallel_test.go @@ -93,7 +93,7 @@ var _ = Describe("pente - parallelism on a single contract", Ordered, func() { }) penteGroupStars := nototypes.PentePrivateGroup{ - Salt: tktypes.Bytes32(tktypes.RandBytes(32)), // unique salt must be shared privately to retain anonymity + Salt: tktypes.RandBytes32(), // unique salt must be shared privately to retain anonymity Members: []string{"tara@node1", "hoshi@node2", "seren@node3"}, // these will be salted to establish the endorsement key identifiers } diff --git a/registries/evm/internal/evmregistry/evm_registry_test.go b/registries/evm/internal/evmregistry/evm_registry_test.go index f91eda646..fcf27c14d 100644 --- a/registries/evm/internal/evmregistry/evm_registry_test.go +++ b/registries/evm/internal/evmregistry/evm_registry_test.go @@ -98,12 +98,12 @@ func TestGoodConfigJSON(t *testing.T) { func TestHandleEventBatchOk(t *testing.T) { - txHash1 := tktypes.Bytes32(tktypes.RandBytes(32)).String() - txHash2 := tktypes.Bytes32(tktypes.RandBytes(32)).String() + txHash1 := tktypes.RandBytes32().String() + txHash2 := tktypes.RandBytes32().String() identityRegistered := IdentityRegisteredEvent{ - ParentIdentityHash: tktypes.Bytes32(tktypes.RandBytes(32)), - IdentityHash: tktypes.Bytes32(tktypes.RandBytes(32)), + ParentIdentityHash: tktypes.RandBytes32(), + IdentityHash: tktypes.RandBytes32(), Name: "node1", Owner: *tktypes.RandAddress(), } @@ -188,7 +188,7 @@ func TestHandleEventBatchOk(t *testing.T) { func TestHandleEventBadIdentityRegistered(t *testing.T) { - txHash := tktypes.Bytes32(tktypes.RandBytes(32)).String() + txHash := tktypes.RandBytes32().String() callbacks := &testCallbacks{} transport := NewEVMRegistry(callbacks).(*evmRegistry) @@ -209,7 +209,7 @@ func TestHandleEventBadIdentityRegistered(t *testing.T) { func TestHandleEventBadSetProperty(t *testing.T) { - txHash := tktypes.Bytes32(tktypes.RandBytes(32)).String() + txHash := tktypes.RandBytes32().String() callbacks := &testCallbacks{} transport := NewEVMRegistry(callbacks).(*evmRegistry) @@ -246,7 +246,7 @@ func TestHandleEventBadSig(t *testing.T) { } func TestHandleEventUnknownSig(t *testing.T) { - txHash := tktypes.Bytes32(tktypes.RandBytes(32)).String() + txHash := tktypes.RandBytes32().String() callbacks := &testCallbacks{} transport := NewEVMRegistry(callbacks).(*evmRegistry) @@ -269,12 +269,12 @@ func TestHandleEventUnknownSig(t *testing.T) { func TestHandleEventBadEntryName(t *testing.T) { - txHash := tktypes.Bytes32(tktypes.RandBytes(32)).String() + txHash := tktypes.RandBytes32().String() callbacks := &testCallbacks{} identityRegistered := IdentityRegisteredEvent{ - ParentIdentityHash: tktypes.Bytes32(tktypes.RandBytes(32)), - IdentityHash: tktypes.Bytes32(tktypes.RandBytes(32)), + ParentIdentityHash: tktypes.RandBytes32(), + IdentityHash: tktypes.RandBytes32(), Name: "___ wrong", Owner: *tktypes.RandAddress(), } @@ -299,10 +299,10 @@ func TestHandleEventBadEntryName(t *testing.T) { func TestHandleEventBatchPropBadName(t *testing.T) { - txHash := tktypes.Bytes32(tktypes.RandBytes(32)).String() + txHash := tktypes.RandBytes32().String() propSet := PropertySetEvent{ - IdentityHash: tktypes.Bytes32(tktypes.RandBytes(32)), + IdentityHash: tktypes.RandBytes32(), Name: "___ wrong", Value: `{"endpoint":"details"}`, } diff --git a/sdk/typescript/scripts/abi.mjs b/sdk/typescript/scripts/abi.mjs index 9dc579515..ac19dd8cb 100644 --- a/sdk/typescript/scripts/abi.mjs +++ b/sdk/typescript/scripts/abi.mjs @@ -5,6 +5,11 @@ await copyFile( "src/domains/abis/PentePrivacyGroup.json" ); +await copyFile( + "../../solidity/artifacts/contracts/domains/interfaces/INoto.sol/INoto.json", + "src/domains/abis/INoto.json" +); + await copyFile( "../../solidity/artifacts/contracts/domains/interfaces/INotoPrivate.sol/INotoPrivate.json", "src/domains/abis/INotoPrivate.json" diff --git a/sdk/typescript/src/domains/noto.ts b/sdk/typescript/src/domains/noto.ts index 6ec1cf0bd..9793182f8 100644 --- a/sdk/typescript/src/domains/noto.ts +++ b/sdk/typescript/src/domains/noto.ts @@ -2,6 +2,7 @@ import { ethers } from "ethers"; import { IGroupInfo, IStateEncoded, TransactionType } from "../interfaces"; import PaladinClient from "../paladin"; import * as notoPrivateJSON from "./abis/INotoPrivate.json"; +import * as notoJSON from "./abis/INoto.json"; import { penteGroupABI } from "./pente"; import { PaladinVerifier } from "../verifier"; @@ -17,35 +18,60 @@ export const notoConstructorABI = ( type: "constructor", inputs: [ { name: "notary", type: "string" }, - { name: "restrictMinting", type: "bool" }, - ...(withHooks - ? [ - { - name: "hooks", - type: "tuple", - components: [ + { name: "notaryMode", type: "string" }, + { + name: "options", + type: "tuple", + components: [ + ...(withHooks + ? [ { - name: "privateGroup", + name: "hooks", type: "tuple", - components: penteGroupABI.components, + components: [ + { + name: "privateGroup", + type: "tuple", + components: penteGroupABI.components, + }, + { name: "publicAddress", type: "address" }, + { name: "privateAddress", type: "address" }, + ], }, - { name: "publicAddress", type: "address" }, - { name: "privateAddress", type: "address" }, - ], - }, - ] - : []), + ] + : [ + { + name: "basic", + type: "tuple", + components: [ + { name: "restrictMint", type: "bool" }, + { name: "allowBurn", type: "bool" }, + { name: "allowLock", type: "bool" }, + { name: "restrictUnlock", type: "bool" }, + ], + }, + ]), + ], + }, ], }); export interface NotoConstructorParams { notary: PaladinVerifier; - hooks?: { - privateGroup?: IGroupInfo; - publicAddress?: string; - privateAddress?: string; + notaryMode: "basic" | "hooks"; + options?: { + basic?: { + restrictMint: boolean; + allowBurn: boolean; + allowLock: boolean; + restrictUnlock: boolean; + }; + hooks?: { + publicAddress: string; + privateGroup?: IGroupInfo; + privateAddress?: string; + }; }; - restrictMinting?: boolean; } export interface NotoMintParams { @@ -72,6 +98,38 @@ export interface NotoApproveTransferParams { delegate: string; } +export interface NotoLockParams { + amount: string | number; + data: string; +} + +export interface NotoUnlockParams { + lockId: string; + from: PaladinVerifier; + recipients: UnlockRecipient[]; + data: string; +} + +export interface UnlockRecipient { + to: PaladinVerifier; + amount: string | number; +} + +export interface NotoDelegateLockParams { + lockId: string; + delegate: string; + data: string; +} + +export interface NotoUnlockPublicParams { + lockId: string; + lockedInputs: string[]; + lockedOutputs: string[]; + outputs: string[]; + signature: string; + data: string; +} + export class NotoFactory { private options: Required; @@ -94,12 +152,22 @@ export class NotoFactory { const txID = await this.paladin.sendTransaction({ type: TransactionType.PRIVATE, domain: this.domain, - abi: [notoConstructorABI(!!data.hooks)], + abi: [notoConstructorABI(!!data.options?.hooks)], function: "", from: from.lookup, data: { ...data, notary: data.notary.lookup, + options: { + basic: { + restrictMint: true, + allowBurn: true, + allowLock: true, + restrictUnlock: true, + ...data.options?.basic, + }, + ...data.options, + }, }, }); const receipt = await this.paladin.pollForReceipt( @@ -200,4 +268,78 @@ export class NotoInstance { }); return this.paladin.pollForReceipt(txID, this.options.pollTimeout); } + + async lock(from: PaladinVerifier, data: NotoLockParams) { + const txID = await this.paladin.sendTransaction({ + type: TransactionType.PRIVATE, + abi: notoPrivateJSON.abi, + function: "lock", + to: this.address, + from: from.lookup, + data, + }); + return this.paladin.pollForReceipt(txID, this.options.pollTimeout); + } + + async unlock(from: PaladinVerifier, data: NotoUnlockParams) { + const txID = await this.paladin.sendTransaction({ + type: TransactionType.PRIVATE, + abi: notoPrivateJSON.abi, + function: "unlock", + to: this.address, + from: from.lookup, + data: { + ...data, + from: data.from.lookup, + recipients: data.recipients.map((recipient) => ({ + to: recipient.to.lookup, + amount: recipient.amount, + })), + }, + }); + return this.paladin.pollForReceipt(txID, this.options.pollTimeout); + } + + async unlockAsDelegate(from: PaladinVerifier, data: NotoUnlockPublicParams) { + const txID = await this.paladin.sendTransaction({ + type: TransactionType.PUBLIC, + abi: notoJSON.abi, + function: "unlock", + to: this.address, + from: from.lookup, + data, + }); + return this.paladin.pollForReceipt(txID, this.options.pollTimeout); + } + + async prepareUnlock(from: PaladinVerifier, data: NotoUnlockParams) { + const txID = await this.paladin.sendTransaction({ + type: TransactionType.PRIVATE, + abi: notoPrivateJSON.abi, + function: "prepareUnlock", + to: this.address, + from: from.lookup, + data: { + ...data, + from: data.from.lookup, + recipients: data.recipients.map((recipient) => ({ + to: recipient.to.lookup, + amount: recipient.amount, + })), + }, + }); + return this.paladin.pollForReceipt(txID, this.options.pollTimeout); + } + + async delegateLock(from: PaladinVerifier, data: NotoDelegateLockParams) { + const txID = await this.paladin.sendTransaction({ + type: TransactionType.PRIVATE, + abi: notoPrivateJSON.abi, + function: "delegateLock", + to: this.address, + from: from.lookup, + data, + }); + return this.paladin.pollForReceipt(txID, this.options.pollTimeout); + } } diff --git a/sdk/typescript/src/domains/pente.ts b/sdk/typescript/src/domains/pente.ts index e3011b385..3e24d117b 100644 --- a/sdk/typescript/src/domains/pente.ts +++ b/sdk/typescript/src/domains/pente.ts @@ -197,7 +197,10 @@ export class PentePrivacyGroup { this.options.pollTimeout, true ); - return receipt?.domainReceipt?.receipt.contractAddress; + return receipt?.domainReceipt !== undefined && + "receipt" in receipt.domainReceipt + ? receipt.domainReceipt.receipt.contractAddress + : undefined; } async invoke( diff --git a/sdk/typescript/src/interfaces/states.ts b/sdk/typescript/src/interfaces/states.ts index 27b3bfcd0..b0402fabe 100644 --- a/sdk/typescript/src/interfaces/states.ts +++ b/sdk/typescript/src/interfaces/states.ts @@ -68,3 +68,10 @@ export interface IStateNullifier { id: string; spent?: IStateSpend; } + +export type StateStatus = + | "available" + | "confirmed" + | "unconfirmed" + | "spent" + | "all"; diff --git a/sdk/typescript/src/interfaces/transaction.ts b/sdk/typescript/src/interfaces/transaction.ts index 2f9780635..6c29c685f 100644 --- a/sdk/typescript/src/interfaces/transaction.ts +++ b/sdk/typescript/src/interfaces/transaction.ts @@ -59,9 +59,10 @@ export interface ITransactionReceipt { success: boolean; transactionHash: string; source: string; + domain?: string; contractAddress?: string; states?: ITransactionStates; - domainReceipt?: IPenteDomainReceipt; + domainReceipt?: IPenteDomainReceipt | INotoDomainReceipt; failureMessage?: string; } @@ -80,6 +81,44 @@ export interface IPenteLog { data: string; } +export interface INotoDomainReceipt { + states: { + inputs?: IReceiptState[]; + outputs?: IReceiptState[]; + readInputs?: IReceiptState[]; + preparedOutputs?: IReceiptState[]; + + lockedInputs?: IReceiptState[]; + lockedOutputs?: IReceiptState[]; + readLockedInputs?: IReceiptState[]; + preparedLockedOutputs?: IReceiptState[]; + }; + lockInfo?: { + lockId: string; + delegate?: string; + unlock?: string; + }; + data?: string; +} + +export interface IReceiptState { + id: string; + data: T; +} + +export interface INotoCoin { + salt: string; + owner: string; + amount: string; +} + +export interface INotoLockedCoin { + lockId: string; + salt: string; + owner: string; + amount: string; +} + export interface ITransactionStates { none?: boolean; spent?: IStateBase[]; diff --git a/sdk/typescript/src/paladin.ts b/sdk/typescript/src/paladin.ts index 248f9b365..52e8f75f5 100644 --- a/sdk/typescript/src/paladin.ts +++ b/sdk/typescript/src/paladin.ts @@ -16,7 +16,13 @@ import { IDecodedEvent, IEventWithData, } from "./interfaces/transaction"; -import { Algorithms, Verifiers } from "./interfaces"; +import { + Algorithms, + ISchema, + IState, + StateStatus, + Verifiers, +} from "./interfaces"; import { ethers, InterfaceAbi } from "ethers"; import { PaladinVerifier } from "./verifier"; @@ -229,4 +235,41 @@ export default class PaladinClient { ); return res.data.result; } + + async listSchemas(domain: string) { + const res = await this.post>( + "pstate_listSchemas", + [domain] + ); + return res.data.result; + } + + async queryStates( + domain: string, + schema: string, + query: IQuery, + status: StateStatus + ) { + const res = await this.post>("pstate_queryStates", [ + domain, + schema, + query, + status, + ]); + return res.data.result; + } + + async queryContractStates( + domain: string, + contractAddress: string, + schema: string, + query: IQuery, + status: StateStatus + ) { + const res = await this.post>( + "pstate_queryContractStates", + [domain, contractAddress, schema, query, status] + ); + return res.data.result; + } } diff --git a/settings.gradle b/settings.gradle index 9ed5fbcc9..d44608d83 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,6 +8,7 @@ include 'domains:zeto' include 'domains:pente' include 'domains:integration-test' include 'example:bond' +include 'example:lock' include 'example:zeto' include 'operator' include 'sdk:typescript' diff --git a/solidity/contracts/domains/interfaces/INoto.sol b/solidity/contracts/domains/interfaces/INoto.sol index b5fc2b430..b8e4c809e 100644 --- a/solidity/contracts/domains/interfaces/INoto.sol +++ b/solidity/contracts/domains/interfaces/INoto.sol @@ -20,6 +20,40 @@ interface INoto { bytes data ); + event NotoLock( + bytes32 lockId, + bytes32[] inputs, + bytes32[] outputs, + bytes32[] lockedOutputs, + bytes signature, + bytes data + ); + + event NotoUnlock( + bytes32 lockId, + address sender, + bytes32[] lockedInputs, + bytes32[] lockedOutputs, + bytes32[] outputs, + bytes signature, + bytes data + ); + + event NotoUnlockPrepared( + bytes32 lockId, + bytes32[] lockedInputs, + bytes32 unlockHash, + bytes signature, + bytes data + ); + + event NotoLockDelegated( + bytes32 lockId, + address delegate, + bytes signature, + bytes data + ); + function initialize( address notaryAddress, bytes calldata data @@ -32,23 +66,56 @@ interface INoto { ) external; function transfer( - bytes32[] memory inputs, - bytes32[] memory outputs, - bytes memory signature, - bytes memory data + bytes32[] calldata inputs, + bytes32[] calldata outputs, + bytes calldata signature, + bytes calldata data ) external; function approveTransfer( address delegate, bytes32 txhash, - bytes memory signature, - bytes memory data + bytes calldata signature, + bytes calldata data ) external; function transferWithApproval( - bytes32[] memory inputs, - bytes32[] memory outputs, - bytes memory signature, - bytes memory data + bytes32[] calldata inputs, + bytes32[] calldata outputs, + bytes calldata signature, + bytes calldata data + ) external; + + function lock( + bytes32 lockId, + bytes32[] calldata inputs, + bytes32[] calldata outputs, + bytes32[] calldata lockedOutputs, + bytes calldata signature, + bytes calldata data + ) external; + + function unlock( + bytes32 lockId, + bytes32[] calldata lockedInputs, + bytes32[] calldata lockedOutputs, + bytes32[] calldata outputs, + bytes calldata signature, + bytes calldata data + ) external; + + function prepareUnlock( + bytes32 lockId, + bytes32[] calldata lockedInputs, + bytes32 unlockHash, + bytes calldata signature, + bytes calldata data + ) external; + + function delegateLock( + bytes32 lockId, + address delegate, + bytes calldata signature, + bytes calldata data ) external; } diff --git a/solidity/contracts/domains/interfaces/INotoPrivate.sol b/solidity/contracts/domains/interfaces/INotoPrivate.sol index 4ed8d995a..c0a08075c 100644 --- a/solidity/contracts/domains/interfaces/INotoPrivate.sol +++ b/solidity/contracts/domains/interfaces/INotoPrivate.sol @@ -19,10 +19,7 @@ interface INotoPrivate { bytes calldata data ) external; - function burn( - uint256 amount, - bytes calldata data - ) external; + function burn(uint256 amount, bytes calldata data) external; function approveTransfer( StateEncoded[] calldata inputs, @@ -31,6 +28,31 @@ interface INotoPrivate { address delegate ) external; + function lock( + uint256 amount, + bytes calldata data + ) external; + + function unlock( + bytes32 lockId, + string calldata from, + UnlockRecipient[] calldata recipients, + bytes calldata data + ) external; + + function prepareUnlock( + bytes32 lockId, + string calldata from, + UnlockRecipient[] calldata recipients, + bytes calldata data + ) external; + + function delegateLock( + bytes32 lockId, + address delegate, + bytes calldata data + ) external; + struct StateEncoded { bytes id; string domain; @@ -38,4 +60,9 @@ interface INotoPrivate { address contractAddress; bytes data; } + + struct UnlockRecipient { + string to; + uint256 amount; + } } diff --git a/solidity/contracts/domains/noto/Noto.sol b/solidity/contracts/domains/noto/Noto.sol index 9a4dc3065..2708acf74 100644 --- a/solidity/contracts/domains/noto/Noto.sol +++ b/solidity/contracts/domains/noto/Noto.sol @@ -27,12 +27,33 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { address _notary; mapping(bytes32 => bool) private _unspent; mapping(bytes32 => address) private _approvals; + mapping(bytes32 => LockDetail) private _locks; error NotoInvalidInput(bytes32 id); error NotoInvalidOutput(bytes32 id); error NotoNotNotary(address sender); error NotoInvalidDelegate(bytes32 txhash, address delegate, address sender); + error NotoLockNotFound(bytes32 lockId); + error NotoInvalidLockState(bytes32 lockId); + error NotoInvalidLockDelegate( + bytes32 lockId, + address delegate, + address sender + ); + error NotoInvalidUnlockHash( + bytes32 lockId, + bytes32 expected, + bytes32 actual + ); + + struct LockDetail { + uint256 stateCount; + mapping(bytes32 => bool) states; + bytes32 unlockHash; + address delegate; + } + // Config follows the convention of a 4 byte type selector, followed by ABI encoded bytes bytes4 public constant NotoConfigID_V0 = 0x00010000; @@ -46,6 +67,10 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { bytes32 private constant TRANSFER_TYPEHASH = keccak256("Transfer(bytes32[] inputs,bytes32[] outputs,bytes data)"); + bytes32 private constant UNLOCK_TYPEHASH = + keccak256( + "Unlock(bytes32[] lockedInputs,bytes32[] lockedOutputs,bytes32[] outputs,bytes data)" + ); function requireNotary(address addr) internal view { if (addr != _notary) { @@ -58,6 +83,16 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { _; } + function requireLockDelegate(bytes32 lockId, address addr) internal view { + if (addr != _locks[lockId].delegate) { + revert NotoInvalidLockDelegate( + lockId, + _locks[lockId].delegate, + addr + ); + } + } + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -70,11 +105,14 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { __EIP712_init("noto", "0.0.1"); _notary = notaryAddress; - return _encodeConfig(NotoConfig_V0({ - notaryAddress: notaryAddress, - data: data, - variant: NotoVariantDefault - })); + return + _encodeConfig( + NotoConfig_V0({ + notaryAddress: notaryAddress, + data: data, + variant: NotoVariantDefault + }) + ); } function _encodeConfig( @@ -99,6 +137,19 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { return _unspent[id]; } + /** + * @dev query whether a TXO is currently locked + * @param lockId the lock identifier + * @param id the UTXO identifier + * @return locked true or false depending on whether the identifier is locked + */ + function isLocked( + bytes32 lockId, + bytes32 id + ) public view returns (bool locked) { + return _locks[lockId].states[id]; + } + /** * @dev query whether an approval exists for the given transaction * @param txhash the transaction hash @@ -111,14 +162,15 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { } /** - * @dev the main function of the contract, which finalizes execution of a pre-verified + * @dev The main function of the contract, which finalizes execution of a pre-verified * transaction. The inputs and outputs are all opaque to this on-chain function. * Provides ordering and double-spend protection. * - * @param inputs Array of zero or more outputs of a previous function call against this - * contract that have not yet been spent, and the signer is authorized to spend. - * @param outputs Array of zero or more new outputs to generate, for future transactions to spend. - * @param data Any additional transaction data (opaque to the blockchain) + * @param inputs array of zero or more outputs of a previous function call against this + * contract that have not yet been spent, and the signer is authorized to spend + * @param outputs array of zero or more new outputs to generate, for future transactions to spend + * @param signature a signature over the original request to the notary (opaque to the blockchain) + * @param data any additional transaction data (opaque to the blockchain) * * Emits a {UTXOTransfer} event. */ @@ -132,7 +184,7 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { } /** - * @dev mint performs a transfer with no input states. Base implementation is identical + * @dev Perform a transfer with no input states. Base implementation is identical * to transfer(), but both methods can be overriden to provide different constraints. */ function mint( @@ -147,32 +199,32 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { function _transfer( bytes32[] memory inputs, bytes32[] memory outputs, - bytes memory signature, - bytes memory data + bytes calldata signature, + bytes calldata data ) internal { - _checkInputs(inputs); - _checkOutputs(outputs); + _processInputs(inputs); + _processOutputs(outputs); emit NotoTransfer(inputs, outputs, signature, data); } /** - * @dev Check the inputs are all existing unspent ids + * @dev Check the inputs are all unspent, and remove them */ - function _checkInputs(bytes32[] memory inputs) internal { + function _processInputs(bytes32[] memory inputs) internal { for (uint256 i = 0; i < inputs.length; ++i) { - if (_unspent[inputs[i]] == false) { + if (!_unspent[inputs[i]]) { revert NotoInvalidInput(inputs[i]); } - delete (_unspent[inputs[i]]); + delete _unspent[inputs[i]]; } } /** - * @dev Check the outputs are all new unspent ids + * @dev Check the outputs are all new, and mark them as unspent */ - function _checkOutputs(bytes32[] memory outputs) internal { + function _processOutputs(bytes32[] memory outputs) internal { for (uint256 i = 0; i < outputs.length; ++i) { - if (_unspent[outputs[i]] == true) { + if (_unspent[outputs[i]]) { revert NotoInvalidOutput(outputs[i]); } _unspent[outputs[i]] = true; @@ -180,8 +232,8 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { } /** - * @dev authorizes an operation to be performed by another address in a future transaction. - * For example, a smart contract coordinating a DVP. + * @dev Authorize a transfer to be performed by another address in a future transaction + * (for example, a smart contract coordinating a DVP). * * Note the txhash will only be spendable if it is exactly correct for * the inputs/outputs/data that are later supplied in useDelegation. @@ -191,6 +243,8 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { * * @param delegate the address that is authorized to submit the transaction * @param txhash the pre-calculated hash of the transaction that is delegated + * @param signature a signature over the original request to the notary (opaque to the blockchain) + * @param data any additional transaction data (opaque to the blockchain) * * Emits a {NotoApproved} event. */ @@ -200,24 +254,16 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { bytes calldata signature, bytes calldata data ) external virtual onlyNotary { - _approveTransfer(delegate, txhash, signature, data); - } - - function _approveTransfer( - address delegate, - bytes32 txhash, - bytes calldata signature, - bytes calldata data - ) internal { _approvals[txhash] = delegate; emit NotoApproved(delegate, txhash, signature, data); } /** - * @dev transfer via delegation - must be the approved delegate + * @dev Transfer via delegation - must be the approved delegate. * * @param inputs as per transfer() * @param outputs as per transfer() + * @param signature as per transfer() * @param data as per transfer() * * Emits a {NotoTransfer} event. @@ -228,7 +274,7 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { bytes calldata signature, bytes calldata data ) public { - bytes32 txhash = _buildTXHash(inputs, outputs, data); + bytes32 txhash = _buildTransferHash(inputs, outputs, data); if (_approvals[txhash] != msg.sender) { revert NotoInvalidDelegate(txhash, _approvals[txhash], msg.sender); } @@ -238,7 +284,7 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { delete _approvals[txhash]; } - function _buildTXHash( + function _buildTransferHash( bytes32[] calldata inputs, bytes32[] calldata outputs, bytes calldata data @@ -253,4 +299,241 @@ contract Noto is EIP712Upgradeable, UUPSUpgradeable, INoto { ); return _hashTypedDataV4(structHash); } + + function _buildUnlockHash( + bytes32[] calldata lockedInputs, + bytes32[] calldata lockedOutputs, + bytes32[] calldata outputs, + bytes calldata data + ) internal view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + UNLOCK_TYPEHASH, + keccak256(abi.encodePacked(lockedInputs)), + keccak256(abi.encodePacked(lockedOutputs)), + keccak256(abi.encodePacked(outputs)), + keccak256(data) + ) + ); + return _hashTypedDataV4(structHash); + } + + /** + * @dev Lock some value so it cannot be spent until it is unlocked. + * + * @param lockId the unique identifier for this lock + * @param inputs array of zero or more outputs of a previous function call against this + * contract that have not yet been spent, and the signer is authorized to spend + * @param outputs array of zero or more new outputs to generate, for future transactions to spend + * @param lockedOutputs array of zero or more locked outputs to generate, which will be tied to the lock ID + * @param signature a signature over the original request to the notary (opaque to the blockchain) + * @param data any additional transaction data (opaque to the blockchain) + * + * Emits a {NotoLock} event. + */ + function lock( + bytes32 lockId, + bytes32[] calldata inputs, + bytes32[] calldata outputs, + bytes32[] calldata lockedOutputs, + bytes calldata signature, + bytes calldata data + ) public virtual override onlyNotary { + _processInputs(inputs); + _processOutputs(outputs); + _processLockedOutputs(lockId, lockedOutputs); + emit NotoLock(lockId, inputs, outputs, lockedOutputs, signature, data); + } + + /** + * @dev Unlock some value from a set of locked states. + * May be triggered by the notary (if lock is undelegated) or by the current lock delegate. + * If triggered by the lock delegate, only a prepared unlock operation may be triggered. + * + * @param lockId the unique identifier for the lock + * @param lockedInputs array of zero or more locked outputs of a previous function call + * @param lockedOutputs array of zero or more locked outputs to generate, which will be tied to the lock ID + * @param outputs array of zero or more new unlocked outputs to generate, for future transactions to spend + * @param signature a signature over the original request to the notary (opaque to the blockchain) + * @param data any additional transaction data (opaque to the blockchain) + * + * Emits a {NotoUnlock} event. + */ + function unlock( + bytes32 lockId, + bytes32[] calldata lockedInputs, + bytes32[] calldata lockedOutputs, + bytes32[] calldata outputs, + bytes calldata signature, + bytes calldata data + ) external virtual override { + LockDetail storage lock_ = _locks[lockId]; + if (lock_.stateCount == 0) { + revert NotoLockNotFound(lockId); + } + + if (lock_.delegate == address(0)) { + requireNotary(msg.sender); + } else { + requireLockDelegate(lockId, msg.sender); + + if (lock_.unlockHash != 0) { + bytes32 unlockHash = _buildUnlockHash( + lockedInputs, + lockedOutputs, + outputs, + data + ); + if (lock_.unlockHash != unlockHash) { + revert NotoInvalidUnlockHash( + lockId, + lock_.unlockHash, + unlockHash + ); + } + } + } + + delete lock_.delegate; + delete lock_.unlockHash; + + _processLockedInputs(lockId, lockedInputs); + _processLockedOutputs(lockId, lockedOutputs); + _processOutputs(outputs); + + emit NotoUnlock( + lockId, + msg.sender, + lockedInputs, + lockedOutputs, + outputs, + signature, + data + ); + } + + /** + * @dev Prepare an unlock operation that can be triggered later. + * May only be triggered by the notary, and only if the lock is not delegated. + * + * @param lockId the unique identifier for the lock + * @param lockedInputs array of zero or more locked outputs of a previous function call + * @param unlockHash pre-calculated EIP-712 hash of the prepared unlock transaction + * @param signature a signature over the original request to the notary (opaque to the blockchain) + * @param data any additional transaction data (opaque to the blockchain) + * + * Emits a {NotoUnlockPrepared} event. + */ + function prepareUnlock( + bytes32 lockId, + bytes32[] calldata lockedInputs, + bytes32 unlockHash, + bytes calldata signature, + bytes calldata data + ) external virtual override onlyNotary { + LockDetail storage lock_ = _locks[lockId]; + if (lock_.stateCount == 0) { + revert NotoLockNotFound(lockId); + } + if (lock_.delegate != address(0)) { + revert NotoInvalidLockState(lockId); + } + + _checkLockedInputs(lockId, lockedInputs); + lock_.unlockHash = unlockHash; + + emit NotoUnlockPrepared( + lockId, + lockedInputs, + unlockHash, + signature, + data + ); + } + + /** + * @dev Change the current delegate for a lock. + * May be triggered by the notary (if lock is undelegated) or by the current lock delegate. + * May only be triggered after an unlock operation has been prepared. + * + * @param lockId the unique identifier for the lock + * @param delegate the address that is authorized to perform the unlock + * @param signature a signature over the original request to the notary (opaque to the blockchain) + * @param data any additional transaction data (opaque to the blockchain) + * + * Emits a {NotoLockDelegated} event. + */ + function delegateLock( + bytes32 lockId, + address delegate, + bytes calldata signature, + bytes calldata data + ) external virtual { + LockDetail storage lock_ = _locks[lockId]; + if (lock_.stateCount == 0) { + revert NotoLockNotFound(lockId); + } + if (lock_.unlockHash == 0) { + revert NotoInvalidLockState(lockId); + } + + if (lock_.delegate == address(0)) { + requireNotary(msg.sender); + } else { + requireLockDelegate(lockId, msg.sender); + } + + lock_.delegate = delegate; + + emit NotoLockDelegated(lockId, delegate, signature, data); + } + + /** + * @dev Check the inputs are all locked + */ + function _checkLockedInputs( + bytes32 id, + bytes32[] calldata inputs + ) internal view { + LockDetail storage lock_ = _locks[id]; + for (uint256 i = 0; i < inputs.length; ++i) { + if (lock_.states[inputs[i]] == false) { + revert NotoInvalidInput(inputs[i]); + } + } + } + + /** + * @dev Check the inputs are all locked, and remove them + */ + function _processLockedInputs( + bytes32 id, + bytes32[] calldata inputs + ) internal { + LockDetail storage lock_ = _locks[id]; + for (uint256 i = 0; i < inputs.length; ++i) { + if (!lock_.states[inputs[i]]) { + revert NotoInvalidInput(inputs[i]); + } + delete lock_.states[inputs[i]]; + lock_.stateCount--; + } + } + + /** + * @dev Check the outputs are all new, and mark them as locked + */ + function _processLockedOutputs( + bytes32 id, + bytes32[] calldata outputs + ) internal { + LockDetail storage lock_ = _locks[id]; + for (uint256 i = 0; i < outputs.length; ++i) { + if (lock_.states[outputs[i]]) { + revert NotoInvalidOutput(outputs[i]); + } + lock_.states[outputs[i]] = true; + lock_.stateCount++; + } + } } diff --git a/solidity/contracts/domains/noto/NotoSelfSubmit.sol b/solidity/contracts/domains/noto/NotoSelfSubmit.sol deleted file mode 100644 index 4d530af8a..000000000 --- a/solidity/contracts/domains/noto/NotoSelfSubmit.sol +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {Noto} from "./Noto.sol"; - -/** - * Noto variant which allows _any_ address to submit a transfer, as long as - * it is accompanied by an EIP-712 signature from the notary. The notary - * signature is recovered and verified. - */ -contract NotoSelfSubmit is Noto { - uint64 public constant NotoVariantSelfSubmit = 0x0001; - - function initialize( - address notaryAddress, - bytes calldata data - ) public override initializer returns (bytes memory) { - __EIP712_init("noto", "0.0.1"); - _notary = notaryAddress; - - return _encodeConfig(NotoConfig_V0({ - notaryAddress: notaryAddress, - data: data, - variant: NotoVariantSelfSubmit - })); - } - - function transfer( - bytes32[] calldata inputs, - bytes32[] calldata outputs, - bytes calldata signature, - bytes calldata data - ) external override { - bytes32 txhash = _buildTXHash(inputs, outputs, data); - address signer = ECDSA.recover(txhash, signature); - requireNotary(signer); - _transfer(inputs, outputs, signature, data); - } - - function approveTransfer( - address delegate, - bytes32 txhash, - bytes calldata signature, - bytes calldata data - ) external override { - address signer = ECDSA.recover(txhash, signature); - requireNotary(signer); - _approveTransfer(delegate, txhash, signature, data); - } -} diff --git a/solidity/contracts/private/BondTracker.sol b/solidity/contracts/private/BondTracker.sol index 15aca2c8b..3bff7e45a 100644 --- a/solidity/contracts/private/BondTracker.sol +++ b/solidity/contracts/private/BondTracker.sol @@ -1,16 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {INotoHooks} from "../private/interfaces/INotoHooks.sol"; +import {NotoTrackerERC20} from "./NotoTrackerERC20.sol"; import {InvestorList} from "./InvestorList.sol"; /** * @title BondTracker * @dev Hook logic to model a simple bond lifecycle on top of Noto. */ -contract BondTracker is INotoHooks, ERC20, Ownable { +contract BondTracker is NotoTrackerERC20 { enum Status { INITIALIZED, ISSUED, @@ -22,10 +20,16 @@ contract BondTracker is INotoHooks, ERC20, Ownable { Status internal _status; address internal _publicTracker; address internal _issuer; + address internal _custodian; InvestorList public investorList; - modifier onlyIssuer() { - require(_msgSender() == _issuer, "Sender is not issuer"); + modifier onlyIssuer(address sender) { + require(sender == _issuer, "Sender is not bond issuer"); + _; + } + + modifier onlyCustodian(address sender) { + require(sender == _custodian, "Sender is not bond custodian"); _; } @@ -34,17 +38,18 @@ contract BondTracker is INotoHooks, ERC20, Ownable { string memory symbol, address custodian, address publicTracker - ) ERC20(name, symbol) Ownable(custodian) { + ) NotoTrackerERC20(name, symbol) { _status = Status.INITIALIZED; _publicTracker = publicTracker; - _issuer = _msgSender(); + _issuer = msg.sender; + _custodian = custodian; investorList = new InvestorList(custodian); } function beginDistribution( uint256 discountPrice, uint256 minimumDenomination - ) external onlyOwner { + ) external onlyCustodian(msg.sender) { require(_status == Status.ISSUED, "Bond has not been issued"); _status = Status.DISTRIBUTION_STARTED; emit PenteExternalCall( @@ -57,7 +62,7 @@ contract BondTracker is INotoHooks, ERC20, Ownable { ); } - function closeDistribution() external onlyOwner { + function closeDistribution() external onlyCustodian(msg.sender) { _status = Status.DISTRIBUTION_CLOSED; emit PenteExternalCall( _publicTracker, @@ -65,7 +70,7 @@ contract BondTracker is INotoHooks, ERC20, Ownable { ); } - function setActive() external onlyIssuer { + function setActive() external onlyIssuer(msg.sender) { require( _status == Status.DISTRIBUTION_CLOSED, "Bond is not ready to be activated" @@ -77,23 +82,47 @@ contract BondTracker is INotoHooks, ERC20, Ownable { ); } + function _checkTransfer( + address sender, + address from, + address to, + uint256 amount + ) internal view { + investorList.checkTransfer(sender, from, to, amount); + } + + function _checkTransfers( + address sender, + address from, + UnlockRecipient[] calldata recipients + ) internal view { + for (uint256 i = 0; i < recipients.length; i++) { + investorList.checkTransfer( + sender, + from, + recipients[i].to, + recipients[i].amount + ); + } + } + function onMint( address sender, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external onlyOwner { - require(sender == _issuer, "Bond must be issued by issuer"); - require(to == owner(), "Bond must be issued to custodian"); + ) external virtual override onlyIssuer(sender) { + require(to == _custodian, "Bond must be issued to custodian"); require(_status == Status.INITIALIZED, "Bond has already been issued"); - _mint(to, amount); + + _onMint(sender, to, amount, data, prepared); _status = Status.ISSUED; emit PenteExternalCall( _publicTracker, abi.encodeWithSignature("onIssue(address,uint256)", to, amount) ); - emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); } function onTransfer( @@ -101,39 +130,39 @@ contract BondTracker is INotoHooks, ERC20, Ownable { address from, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external onlyOwner { - investorList.checkTransfer(sender, from, to, amount); - _transfer(from, to, amount); + ) external virtual override onlySelf(sender, from) { + _checkTransfer(sender, from, to, amount); + _onTransfer(sender, from, to, amount, data, prepared); - if (_status == Status.DISTRIBUTION_STARTED && from == owner()) { + if (_status == Status.DISTRIBUTION_STARTED && from == _custodian) { emit PenteExternalCall( _publicTracker, abi.encodeWithSignature("onDistribute(uint256)", amount) ); } - emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); } - uint256 approvals; - - function onApproveTransfer( + function onUnlock( address sender, - address from, - address delegate, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, PreparedTransaction calldata prepared - ) external onlyOwner { - approvals++; // must store something on each call (see https://github.com/kaleido-io/paladin/issues/252) - emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); + ) external virtual override onlyLockOwner(sender, lockId) { + _checkTransfers(sender, _locks.ownerOf(lockId), recipients); + _onUnlock(sender, lockId, recipients, data, prepared); } - function onBurn( + function onPrepareUnlock( address sender, - address from, - uint256 amount, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, PreparedTransaction calldata prepared - ) external override { - _burn(from, amount); - emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); + ) external virtual override onlyLockOwner(sender, lockId) { + _checkTransfers(sender, _locks.ownerOf(lockId), recipients); + _onPrepareUnlock(sender, lockId, recipients, data, prepared); } } diff --git a/solidity/contracts/private/NotoLocks.sol b/solidity/contracts/private/NotoLocks.sol new file mode 100644 index 000000000..e0a819fe2 --- /dev/null +++ b/solidity/contracts/private/NotoLocks.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import {INotoHooks} from "./interfaces/INotoHooks.sol"; + +/** + * Helpers for tracking locked amounts from Noto hooks contracts. + */ +contract NotoLocks { + // Details on all currently active locks and their possible unlocks + mapping(bytes32 => LockDetail) internal _locks; + + // Balances locked by address (still logically owned by that address) + mapping(address => uint256) public lockedBalance; + + // Pending balances from prepared unlocks (not yet owned, but approved to be owned when unlocked) + mapping(address => uint256) public pendingBalance; + + struct LockDetail { + address from; + uint256 amount; + INotoHooks.UnlockRecipient[] recipients; + } + + function getLock(bytes32 lockId) public view returns (LockDetail memory) { + return _locks[lockId]; + } + + function ownerOf(bytes32 lockId) public view returns (address) { + return _locks[lockId].from; + } + + function onLock(bytes32 lockId, address from, uint256 amount) public { + LockDetail storage lock = _locks[lockId]; + lock.from = from; + lock.amount = amount; + lockedBalance[from] += amount; + } + + function onUnlock( + bytes32 lockId, + INotoHooks.UnlockRecipient[] calldata recipients + ) public { + LockDetail storage lock = _locks[lockId]; + for (uint256 i = 0; i < recipients.length; i++) { + lock.amount -= recipients[i].amount; + lockedBalance[lock.from] -= recipients[i].amount; + } + + delete lock.recipients; + if (lock.amount == 0) { + delete _locks[lockId]; + } + } + + function onPrepareUnlock( + bytes32 lockId, + INotoHooks.UnlockRecipient[] calldata recipients + ) public { + LockDetail storage lock = _locks[lockId]; + delete lock.recipients; + + for (uint256 i = 0; i < recipients.length; i++) { + pendingBalance[recipients[i].to] += recipients[i].amount; + lock.recipients.push(recipients[i]); + } + } + + function handleDelegateUnlock( + bytes32 lockId, + INotoHooks.UnlockRecipient[] calldata recipients + ) public { + LockDetail storage lock = _locks[lockId]; + for (uint256 i = 0; i < lock.recipients.length; i++) { + pendingBalance[lock.recipients[i].to] -= lock.recipients[i].amount; + } + onUnlock(lockId, recipients); + } +} diff --git a/solidity/contracts/private/NotoTrackerERC20.sol b/solidity/contracts/private/NotoTrackerERC20.sol index 94b9d1351..8196c9359 100644 --- a/solidity/contracts/private/NotoTrackerERC20.sol +++ b/solidity/contracts/private/NotoTrackerERC20.sol @@ -3,40 +3,142 @@ pragma solidity ^0.8.20; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {INotoHooks} from "../private/interfaces/INotoHooks.sol"; +import {NotoLocks} from "./NotoLocks.sol"; /** + * @title NotoTrackerERC20 * @dev Example Noto hooks which track all Noto token movements on a private ERC20. */ contract NotoTrackerERC20 is INotoHooks, ERC20 { - constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + NotoLocks internal _locks; + address internal _notary; - function onMint( + modifier onlyNotary(address sender) { + require(sender == _notary, "Sender is not the notary"); + _; + } + + modifier onlySelf(address sender, address from) { + require(sender == from, "Sender is not the from address"); + _; + } + + modifier onlyLockOwner(address sender, bytes32 lockId) { + require( + sender == _locks.ownerOf(lockId), + "Sender is not the lock owner" + ); + _; + } + + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _locks = new NotoLocks(); + _notary = msg.sender; + } + + function _onMint( address sender, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external { + ) internal virtual { _mint(to, amount); emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); } - function onTransfer( + function _onTransfer( address sender, address from, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external { + ) internal virtual { _transfer(from, to, amount); emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); } + function _onBurn( + address sender, + address from, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) internal virtual { + _burn(from, amount); + emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); + } + + function _onLock( + address sender, + bytes32 lockId, + address from, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) internal virtual { + _locks.onLock(lockId, from, amount); + emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); + } + + function _onUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) internal virtual { + address from = _locks.ownerOf(lockId); + _locks.onUnlock(lockId, recipients); + for (uint256 i = 0; i < recipients.length; i++) { + _transfer(from, recipients[i].to, recipients[i].amount); + } + emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); + } + + function _onPrepareUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) internal virtual { + _locks.onPrepareUnlock(lockId, recipients); + emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); + } + + function onMint( + address sender, + address to, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) external virtual override onlyNotary(sender) { + _onMint(sender, to, amount, data, prepared); + } + + function onTransfer( + address sender, + address from, + address to, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) external virtual override onlySelf(sender, from) { + _onTransfer(sender, from, to, amount, data, prepared); + } + + uint256 approvals; + function onApproveTransfer( address sender, address from, address delegate, + bytes calldata data, PreparedTransaction calldata prepared - ) external { + ) external virtual override { + approvals++; // must store something on each call (see https://github.com/kaleido-io/paladin/issues/252) emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); } @@ -44,9 +146,62 @@ contract NotoTrackerERC20 is INotoHooks, ERC20 { address sender, address from, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external override { - _burn(from, amount); + ) external virtual override { + _onBurn(sender, from, amount, data, prepared); + } + + function onLock( + address sender, + bytes32 lockId, + address from, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) external virtual override { + _onLock(sender, lockId, from, amount, data, prepared); + } + + function onUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) external virtual override onlyLockOwner(sender, lockId) { + _onUnlock(sender, lockId, recipients, data, prepared); + } + + function onPrepareUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) external virtual override onlyLockOwner(sender, lockId) { + _onPrepareUnlock(sender, lockId, recipients, data, prepared); + } + + function onDelegateLock( + address sender, + bytes32 lockId, + address delegate, + PreparedTransaction calldata prepared + ) external virtual override { emit PenteExternalCall(prepared.contractAddress, prepared.encodedCall); } + + function handleDelegateUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data + ) external virtual override { + address from = _locks.ownerOf(lockId); + _locks.handleDelegateUnlock(lockId, recipients); + for (uint256 i = 0; i < recipients.length; i++) { + _transfer(from, recipients[i].to, recipients[i].amount); + } + } } diff --git a/solidity/contracts/private/interfaces/INotoHooks.sol b/solidity/contracts/private/interfaces/INotoHooks.sol index 9a0ffa85f..cebdffd38 100644 --- a/solidity/contracts/private/interfaces/INotoHooks.sol +++ b/solidity/contracts/private/interfaces/INotoHooks.sol @@ -6,6 +6,10 @@ import {IPenteExternalCall} from "./IPenteExternalCall.sol"; /** * @dev Noto hooks can be deployed privately on top of Pente, to receive prepared transactions * from Noto in order to perform final checking and submission to the base ledger. + * Unless otherwise noted, each hook should always have one of two outcomes: + * - success: the hook should emit "PenteExternalCall" with the prepared transaction in + * order to continue submission of the transaction to the base ledger + * - failure: the hook should revert with a reason */ interface INotoHooks is IPenteExternalCall { struct PreparedTransaction { @@ -13,10 +17,16 @@ interface INotoHooks is IPenteExternalCall { bytes encodedCall; } + struct UnlockRecipient { + address to; + uint256 amount; + } + function onMint( address sender, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared ) external; @@ -25,6 +35,7 @@ interface INotoHooks is IPenteExternalCall { address from, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared ) external; @@ -32,6 +43,7 @@ interface INotoHooks is IPenteExternalCall { address sender, address from, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared ) external; @@ -39,6 +51,52 @@ interface INotoHooks is IPenteExternalCall { address sender, address from, address delegate, + bytes calldata data, + PreparedTransaction calldata prepared + ) external; + + function onLock( + address sender, + bytes32 lockId, + address from, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) external; + + function onUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) external; + + function onPrepareUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) external; + + function onDelegateLock( + address sender, + bytes32 lockId, + address delegate, PreparedTransaction calldata prepared ) external; + + /** + * @dev This method is called after a prepared unlock is executed by the lock delegate. + * Unlike other hooks, this method is called after the unlock has been confirmed. + * Therefore, this method should never revert, but should only update the state of + * the hook contract to reflect the unlock. + */ + function handleDelegateUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data + ) external; } diff --git a/solidity/contracts/testcontracts/NotoTrackerPublicERC20.sol b/solidity/contracts/testcontracts/NotoTrackerPublicERC20.sol index f784a66d7..39717bd09 100644 --- a/solidity/contracts/testcontracts/NotoTrackerPublicERC20.sol +++ b/solidity/contracts/testcontracts/NotoTrackerPublicERC20.sol @@ -17,8 +17,9 @@ contract NotoTrackerPublicERC20 is INotoHooks, ERC20 { address sender, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external { + ) external override { _mint(to, amount); _executeOperation(prepared); } @@ -28,8 +29,9 @@ contract NotoTrackerPublicERC20 is INotoHooks, ERC20 { address from, address to, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared - ) external { + ) external override { _transfer(from, to, amount); _executeOperation(prepared); } @@ -38,8 +40,9 @@ contract NotoTrackerPublicERC20 is INotoHooks, ERC20 { address sender, address from, address delegate, + bytes calldata data, PreparedTransaction calldata prepared - ) external { + ) external override { _executeOperation(prepared); } @@ -47,12 +50,62 @@ contract NotoTrackerPublicERC20 is INotoHooks, ERC20 { address sender, address from, uint256 amount, + bytes calldata data, PreparedTransaction calldata prepared ) external override { _burn(from, amount); _executeOperation(prepared); } + function onLock( + address sender, + bytes32 lockId, + address from, + uint256 amount, + bytes calldata data, + PreparedTransaction calldata prepared + ) external override { + revert("Lock not supported"); + } + + function onUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) external override { + // do nothing + } + + function onPrepareUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data, + PreparedTransaction calldata prepared + ) external override { + // do nothing + } + + function onDelegateLock( + address sender, + bytes32 lockId, + address delegate, + PreparedTransaction calldata prepared + ) external override { + // do nothing + } + + function handleDelegateUnlock( + address sender, + bytes32 lockId, + UnlockRecipient[] calldata recipients, + bytes calldata data + ) external override { + // do nothing + } + function _executeOperation(PreparedTransaction memory op) internal { (bool success, bytes memory result) = op.contractAddress.call( op.encodedCall diff --git a/solidity/test/domains/noto/Noto.ts b/solidity/test/domains/noto/Noto.ts index 2b9fec21a..3e0cea090 100644 --- a/solidity/test/domains/noto/Noto.ts +++ b/solidity/test/domains/noto/Noto.ts @@ -1,63 +1,18 @@ import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; -import { randomBytes } from "crypto"; +import { ethers } from "hardhat"; +import { Noto } from "../../../typechain-types"; import { - AbiCoder, - ContractTransactionReceipt, - Signer, - TypedDataEncoder, -} from "ethers"; -import hre, { ethers } from "hardhat"; -import { NotoFactory, Noto } from "../../../typechain-types"; - -export async function newTransferHash( - noto: Noto, - inputs: string[], - outputs: string[], - data: string -) { - const domain = { - name: "noto", - version: "0.0.1", - chainId: hre.network.config.chainId, - verifyingContract: await noto.getAddress(), - }; - const types = { - Transfer: [ - { name: "inputs", type: "bytes32[]" }, - { name: "outputs", type: "bytes32[]" }, - { name: "data", type: "bytes" }, - ], - }; - const value = { inputs, outputs, data }; - return { - hash: TypedDataEncoder.hash(domain, types, value), - }; -} - -export function randomBytes32() { - return "0x" + Buffer.from(randomBytes(32)).toString("hex"); -} - -export function fakeTXO() { - return randomBytes32(); -} - -export async function deployNotoInstance( - notoFactory: NotoFactory, - notary: string -) { - const abi = AbiCoder.defaultAbiCoder(); - const deployTx = await notoFactory.deploy(randomBytes32(), notary, "0x"); - const deployReceipt = await deployTx.wait(); - const deployEvent = deployReceipt?.logs.find( - (l) => - notoFactory.interface.parseLog(l)?.name === - "PaladinRegisterSmartContract_V0" - ); - expect(deployEvent).to.exist; - return deployEvent && "args" in deployEvent ? deployEvent.args.instance : ""; -} + deployNotoInstance, + doDelegateLock, + doLock, + doPrepareUnlock, + doTransfer, + doUnlock, + fakeTXO, + newUnlockHash, + randomBytes32, +} from "./util"; describe("Noto", function () { async function deployNotoFixture() { @@ -73,30 +28,6 @@ describe("Noto", function () { return { noto: noto as Noto, notary, other }; } - async function doTransfer( - notary: Signer, - noto: Noto, - inputs: string[], - outputs: string[], - data: string - ) { - const tx = await noto.connect(notary).transfer(inputs, outputs, "0x", data); - const results: ContractTransactionReceipt | null = await tx.wait(); - - for (const log of results?.logs || []) { - const event = noto.interface.parseLog(log as any); - expect(event?.args.inputs).to.deep.equal(inputs); - expect(event?.args.outputs).to.deep.equal(outputs); - expect(event?.args.data).to.deep.equal(data); - } - for (const input of inputs) { - expect(await noto.isUnspent(input)).to.equal(false); - } - for (const output of outputs) { - expect(await noto.isUnspent(output)).to.equal(true); - } - } - it("UTXO lifecycle and double-spend protections", async function () { const { noto, notary } = await loadFixture(deployNotoFixture); @@ -131,4 +62,98 @@ describe("Noto", function () { // Spend the last one await doTransfer(notary, noto, [txo3], [], randomBytes32()); }); + + it("lock and unlock", async function () { + const { noto, notary } = await loadFixture(deployNotoFixture); + const [_, delegate] = await ethers.getSigners(); + expect(notary.address).to.not.equal(delegate.address); + + const txo1 = fakeTXO(); + const txo2 = fakeTXO(); + const txo3 = fakeTXO(); + const txo4 = fakeTXO(); + const txo5 = fakeTXO(); + + const locked1 = fakeTXO(); + const locked2 = fakeTXO(); + + // Make two UTXOs + await doTransfer(notary, noto, [], [txo1, txo2], randomBytes32()); + + // Lock both of them + const lockId = randomBytes32(); + await doLock( + notary, + noto, + lockId, + [txo1, txo2], + [txo3], + [locked1], + randomBytes32() + ); + + // Check that the same state cannot be locked again with the same lock + await expect( + doLock(notary, noto, lockId, [], [], [locked1], randomBytes32()) + ).to.be.rejectedWith("NotoInvalidOutput"); + + // Check that locked value cannot be spent + await expect( + doTransfer(notary, noto, [locked1], [], randomBytes32()) + ).to.be.rejectedWith("NotoInvalidInput"); + + // Unlock the UTXO + await doUnlock( + notary, + noto, + lockId, + [locked1], + [locked2], + [txo4], + randomBytes32() + ); + + // Check that the same state cannot be unlocked again + await expect( + doUnlock(notary, noto, lockId, [locked1], [], [], randomBytes32()) + ).to.be.rejectedWith("NotoInvalidInput"); + + // Prepare an unlock operation + const unlockData = randomBytes32(); + const unlockHash = await newUnlockHash( + noto, + [locked2], + [], + [txo5], + unlockData + ); + await doPrepareUnlock( + notary, + noto, + lockId, + [locked2], + unlockHash, + unlockData + ); + + // Delegate the unlock + await doDelegateLock( + notary, + noto, + lockId, + delegate.address, + randomBytes32() + ); + + // Attempt to perform an incorrect unlock + await expect( + doUnlock(delegate, noto, lockId, [locked2], [], [], unlockData) // missing output state + ).to.be.rejectedWith("NotoInvalidUnlockHash"); + await expect( + doUnlock(delegate, noto, lockId, [locked2], [], [txo5], randomBytes32()) // wrong data + ).to.be.rejectedWith("NotoInvalidUnlockHash"); + + // Perform the prepared unlock + await doUnlock(delegate, noto, lockId, [locked2], [], [txo5], unlockData); + }); }); diff --git a/solidity/test/domains/noto/NotoSelfSubmit.ts b/solidity/test/domains/noto/NotoSelfSubmit.ts deleted file mode 100644 index 5c853488e..000000000 --- a/solidity/test/domains/noto/NotoSelfSubmit.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; -import { expect } from "chai"; -import { AbiCoder, ContractTransactionReceipt, Signer } from "ethers"; -import hre, { ethers } from "hardhat"; -import { NotoFactory, NotoSelfSubmit } from "../../../typechain-types"; -import { fakeTXO, randomBytes32 } from "./Noto"; - -export async function prepareSignature( - noto: NotoSelfSubmit, - notary: Signer, - inputs: string[], - outputs: string[], - data: string -) { - const domain = { - name: "noto", - version: "0.0.1", - chainId: hre.network.config.chainId, - verifyingContract: await noto.getAddress(), - }; - const types = { - Transfer: [ - { name: "inputs", type: "bytes32[]" }, - { name: "outputs", type: "bytes32[]" }, - { name: "data", type: "bytes" }, - ], - }; - const value = { inputs, outputs, data }; - return notary.signTypedData(domain, types, value); -} - -export async function deployNotoInstance( - notoFactory: NotoFactory, - notary: string -) { - const abi = AbiCoder.defaultAbiCoder(); - const Noto = await ethers.getContractFactory("NotoSelfSubmit"); - const notoImpl = await Noto.deploy(); - await notoFactory.registerImplementation("selfsubmit", notoImpl); - const deployTx = await notoFactory.deployImplementation( - "selfsubmit", - randomBytes32(), - notary, - "0x" - ); - const deployReceipt = await deployTx.wait(); - const deployEvent = deployReceipt?.logs.find( - (l) => - notoFactory.interface.parseLog(l)?.name === - "PaladinRegisterSmartContract_V0" - ); - expect(deployEvent).to.exist; - return deployEvent && "args" in deployEvent ? deployEvent.args.instance : ""; -} - -describe("NotoSelfSubmit", function () { - async function deployNotoFixture() { - const [notary, other] = await ethers.getSigners(); - const abi = AbiCoder.defaultAbiCoder(); - - const NotoFactory = await ethers.getContractFactory("NotoFactory"); - const notoFactory = await NotoFactory.deploy(); - const Noto = await ethers.getContractFactory("Noto"); - const noto = Noto.attach( - await deployNotoInstance(notoFactory, notary.address) - ); - - return { noto: noto as NotoSelfSubmit, notary, other }; - } - - async function doTransfer( - notary: Signer, - submitter: Signer, - noto: NotoSelfSubmit, - inputs: string[], - outputs: string[], - data: string - ) { - const signature = await prepareSignature( - noto, - notary, - inputs, - outputs, - data - ); - const tx = await noto - .connect(submitter) - .transfer(inputs, outputs, signature, data); - const results: ContractTransactionReceipt | null = await tx.wait(); - - for (const log of results?.logs || []) { - const event = noto.interface.parseLog(log as any); - expect(event?.args.inputs).to.deep.equal(inputs); - expect(event?.args.outputs).to.deep.equal(outputs); - expect(event?.args.data).to.deep.equal(data); - } - for (const input of inputs) { - expect(await noto.isUnspent(input)).to.equal(false); - } - for (const output of outputs) { - expect(await noto.isUnspent(output)).to.equal(true); - } - } - - it("UTXO lifecycle and double-spend protections", async function () { - const { noto, notary, other } = await loadFixture(deployNotoFixture); - - const txo1 = fakeTXO(); - const txo2 = fakeTXO(); - const txo3 = fakeTXO(); - - // Make two UTXOs - await doTransfer(notary, other, noto, [], [txo1, txo2], randomBytes32()); - - // Check for double-mint protection - await expect( - doTransfer(notary, other, noto, [], [txo1], randomBytes32()) - ).rejectedWith("NotoInvalidOutput"); - - // Check for spend unknown protection - await expect( - doTransfer(notary, other, noto, [txo3], [], randomBytes32()) - ).rejectedWith("NotoInvalidInput"); - - // Spend one - await doTransfer(notary, other, noto, [txo1], [txo3], randomBytes32()); - - // Check for double-spend protection - await expect( - doTransfer(notary, other, noto, [txo1], [txo3], randomBytes32()) - ).rejectedWith("NotoInvalidInput"); - - // Spend another - await doTransfer(notary, other, noto, [txo2], [], randomBytes32()); - - // Spend the last one - await doTransfer(notary, other, noto, [txo3], [], randomBytes32()); - }); -}); diff --git a/solidity/test/domains/noto/util.ts b/solidity/test/domains/noto/util.ts new file mode 100644 index 000000000..4cdb37d74 --- /dev/null +++ b/solidity/test/domains/noto/util.ts @@ -0,0 +1,225 @@ +import { expect } from "chai"; +import { randomBytes } from "crypto"; +import { Signer, TypedDataEncoder } from "ethers"; +import hre from "hardhat"; +import { Noto, NotoFactory } from "../../../typechain-types"; + +export async function newTransferHash( + noto: Noto, + inputs: string[], + outputs: string[], + data: string +) { + const domain = { + name: "noto", + version: "0.0.1", + chainId: hre.network.config.chainId, + verifyingContract: await noto.getAddress(), + }; + const types = { + Transfer: [ + { name: "inputs", type: "bytes32[]" }, + { name: "outputs", type: "bytes32[]" }, + { name: "data", type: "bytes" }, + ], + }; + const value = { inputs, outputs, data }; + return TypedDataEncoder.hash(domain, types, value); +} + +export async function newUnlockHash( + noto: Noto, + lockedInputs: string[], + lockedOutputs: string[], + outputs: string[], + data: string +) { + const domain = { + name: "noto", + version: "0.0.1", + chainId: hre.network.config.chainId, + verifyingContract: await noto.getAddress(), + }; + const types = { + Unlock: [ + { name: "lockedInputs", type: "bytes32[]" }, + { name: "lockedOutputs", type: "bytes32[]" }, + { name: "outputs", type: "bytes32[]" }, + { name: "data", type: "bytes" }, + ], + }; + const value = { lockedInputs, lockedOutputs, outputs, data }; + return TypedDataEncoder.hash(domain, types, value); +} + +export function randomBytes32() { + return "0x" + Buffer.from(randomBytes(32)).toString("hex"); +} + +export function fakeTXO() { + return randomBytes32(); +} + +export async function deployNotoInstance( + notoFactory: NotoFactory, + notary: string +) { + const deployTx = await notoFactory.deploy(randomBytes32(), notary, "0x"); + const deployReceipt = await deployTx.wait(); + const deployEvent = deployReceipt?.logs.find( + (l) => + notoFactory.interface.parseLog(l)?.name === + "PaladinRegisterSmartContract_V0" + ); + expect(deployEvent).to.exist; + return deployEvent && "args" in deployEvent ? deployEvent.args.instance : ""; +} + +export async function doTransfer( + notary: Signer, + noto: Noto, + inputs: string[], + outputs: string[], + data: string +) { + const tx = await noto.connect(notary).transfer(inputs, outputs, "0x", data); + const results = await tx.wait(); + expect(results).to.exist; + + for (const log of results?.logs || []) { + const event = noto.interface.parseLog(log); + expect(event).to.exist; + expect(event?.name).to.equal("NotoTransfer"); + expect(event?.args.inputs).to.deep.equal(inputs); + expect(event?.args.outputs).to.deep.equal(outputs); + expect(event?.args.data).to.deep.equal(data); + } + for (const input of inputs) { + expect(await noto.isUnspent(input)).to.equal(false); + } + for (const output of outputs) { + expect(await noto.isUnspent(output)).to.equal(true); + } +} + +export async function doLock( + notary: Signer, + noto: Noto, + lockId: string, + inputs: string[], + outputs: string[], + lockedOutputs: string[], + data: string +) { + const tx = await noto + .connect(notary) + .lock(lockId, inputs, outputs, lockedOutputs, "0x", data); + const results = await tx.wait(); + expect(results).to.exist; + + for (const log of results?.logs || []) { + const event = noto.interface.parseLog(log); + expect(event).to.exist; + expect(event?.name).to.equal("NotoLock"); + expect(event?.args.inputs).to.deep.equal(inputs); + expect(event?.args.outputs).to.deep.equal(outputs); + expect(event?.args.lockedOutputs).to.deep.equal(lockedOutputs); + expect(event?.args.data).to.deep.equal(data); + } + for (const input of inputs) { + expect(await noto.isUnspent(input)).to.equal(false); + } + for (const output of outputs) { + expect(await noto.isUnspent(output)).to.equal(true); + } + for (const output of lockedOutputs) { + expect(await noto.isLocked(lockId, output)).to.equal(true); + expect(await noto.isUnspent(output)).to.equal(false); + } +} + +export async function doUnlock( + sender: Signer, + noto: Noto, + lockId: string, + lockedInputs: string[], + lockedOutputs: string[], + outputs: string[], + data: string +) { + const tx = await noto + .connect(sender) + .unlock(lockId, lockedInputs, lockedOutputs, outputs, "0x", data); + const results = await tx.wait(); + expect(results).to.exist; + + for (const log of results?.logs || []) { + const event = noto.interface.parseLog(log); + expect(event).to.exist; + expect(event?.name).to.equal("NotoUnlock"); + expect(event?.args.lockedInputs).to.deep.equal(lockedInputs); + expect(event?.args.lockedOutputs).to.deep.equal(lockedOutputs); + expect(event?.args.outputs).to.deep.equal(outputs); + expect(event?.args.data).to.deep.equal(data); + } + for (const input of lockedInputs) { + expect(await noto.isLocked(lockId, input)).to.equal(false); + expect(await noto.isUnspent(input)).to.equal(false); + } + for (const output of lockedOutputs) { + expect(await noto.isLocked(lockId, output)).to.equal(true); + expect(await noto.isUnspent(output)).to.equal(false); + } + for (const output of outputs) { + expect(await noto.isUnspent(output)).to.equal(true); + } +} + +export async function doPrepareUnlock( + notary: Signer, + noto: Noto, + lockId: string, + lockedInputs: string[], + unlockHash: string, + data: string +) { + const tx = await noto + .connect(notary) + .prepareUnlock(lockId, lockedInputs, unlockHash, "0x", data); + const results = await tx.wait(); + expect(results).to.exist; + + for (const log of results?.logs || []) { + const event = noto.interface.parseLog(log); + expect(event).to.exist; + expect(event?.name).to.equal("NotoUnlockPrepared"); + expect(event?.args.lockedInputs).to.deep.equal(lockedInputs); + expect(event?.args.unlockHash).to.deep.equal(unlockHash); + expect(event?.args.data).to.deep.equal(data); + } + for (const input of lockedInputs) { + expect(await noto.isLocked(lockId, input)).to.equal(true); + } +} + +export async function doDelegateLock( + notary: Signer, + noto: Noto, + lockId: string, + delegate: string, + data: string +) { + const tx = await noto + .connect(notary) + .delegateLock(lockId, delegate, "0x", data); + const results = await tx.wait(); + expect(results).to.exist; + + for (const log of results?.logs || []) { + const event = noto.interface.parseLog(log); + expect(event).to.exist; + expect(event?.name).to.equal("NotoLockDelegated"); + expect(event?.args.delegate).to.deep.equal(delegate); + expect(event?.args.data).to.deep.equal(data); + } +} diff --git a/solidity/test/shared/atom/Atom.ts b/solidity/test/shared/atom/Atom.ts index 95f302784..f9e3fd041 100644 --- a/solidity/test/shared/atom/Atom.ts +++ b/solidity/test/shared/atom/Atom.ts @@ -7,7 +7,7 @@ import { fakeTXO, newTransferHash, randomBytes32, -} from "../../domains/noto/Noto"; +} from "../../domains/noto/util"; describe("Atom", function () { it("atomic operation with 2 encoded calls", async function () { @@ -77,14 +77,14 @@ describe("Atom", function () { // Do the delegation/approval transactions const f1tx = await noto .connect(notary1) - .approveTransfer(mcAddr, multiTXF1Part.hash, "0x", "0x"); + .approveTransfer(mcAddr, multiTXF1Part, "0x", "0x"); const delegateResult1: ContractTransactionReceipt | null = await f1tx.wait(); const delegateEvent1 = noto.interface.parseLog( delegateResult1?.logs[0] as any )!.args; expect(delegateEvent1.delegate).to.equal(mcAddr); - expect(delegateEvent1.txhash).to.equal(multiTXF1Part.hash); + expect(delegateEvent1.txhash).to.equal(multiTXF1Part); await erc20.approve(mcAddr, 1000); // Run the atomic op (anyone can initiate) @@ -151,6 +151,6 @@ describe("Atom", function () { const atom = Atom.connect(anybody2).attach(mcAddr) as Atom; await expect(atom.execute()) .to.be.revertedWithCustomError(Noto, "NotoInvalidDelegate") - .withArgs(multiTXF1Part.hash, ZeroAddress, mcAddr); + .withArgs(multiTXF1Part, ZeroAddress, mcAddr); }); }); diff --git a/toolkit/go/pkg/domain/callbacks.go b/toolkit/go/pkg/domain/callbacks.go new file mode 100644 index 000000000..f98a7eb24 --- /dev/null +++ b/toolkit/go/pkg/domain/callbacks.go @@ -0,0 +1,54 @@ +/* + * Copyright © 2024 Kaleido, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package domain + +import ( + "context" + + "github.com/kaleido-io/paladin/toolkit/pkg/prototk" +) + +type MockDomainCallbacks struct { + MockFindAvailableStates func() (*prototk.FindAvailableStatesResponse, error) + MockLocalNodeName func() (*prototk.LocalNodeNameResponse, error) +} + +func (dc *MockDomainCallbacks) FindAvailableStates(ctx context.Context, req *prototk.FindAvailableStatesRequest) (*prototk.FindAvailableStatesResponse, error) { + return dc.MockFindAvailableStates() +} + +func (dc *MockDomainCallbacks) EncodeData(ctx context.Context, req *prototk.EncodeDataRequest) (*prototk.EncodeDataResponse, error) { + return nil, nil +} +func (dc *MockDomainCallbacks) RecoverSigner(ctx context.Context, req *prototk.RecoverSignerRequest) (*prototk.RecoverSignerResponse, error) { + return nil, nil +} + +func (dc *MockDomainCallbacks) DecodeData(context.Context, *prototk.DecodeDataRequest) (*prototk.DecodeDataResponse, error) { + return nil, nil +} + +func (dc *MockDomainCallbacks) SendTransaction(context.Context, *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) { + return nil, nil +} + +func (dc *MockDomainCallbacks) LocalNodeName(context.Context, *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) { + return dc.MockLocalNodeName() +} + +func (dc *MockDomainCallbacks) GetStates(context.Context, *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) { + return nil, nil +} diff --git a/toolkit/go/pkg/pldclient/txbuilder_test.go b/toolkit/go/pkg/pldclient/txbuilder_test.go index b28ae8714..b5e71ae40 100644 --- a/toolkit/go/pkg/pldclient/txbuilder_test.go +++ b/toolkit/go/pkg/pldclient/txbuilder_test.go @@ -122,7 +122,7 @@ func TestBuildAndSubmitPublicTXHTTPOk(t *testing.T) { contractAddr := tktypes.RandAddress() txID := uuid.New() - txHash := tktypes.Bytes32(tktypes.RandBytes(32)) + txHash := tktypes.RandBytes32() rpcServer.Register(rpcserver.NewRPCModule("ptx"). Add( @@ -710,7 +710,7 @@ func TestGetters(t *testing.T) { IdempotencyKey: "tx1", Type: pldapi.TransactionTypePrivate.Enum(), Domain: "domain1", - ABIReference: confutil.P(tktypes.Bytes32(tktypes.RandBytes(32))), + ABIReference: confutil.P(tktypes.RandBytes32()), From: "tx.sender", To: tktypes.RandAddress(), Function: "function1", diff --git a/toolkit/go/pkg/plugintk/plugin_type_domain.go b/toolkit/go/pkg/plugintk/plugin_type_domain.go index 229e9ef11..2e03e3446 100644 --- a/toolkit/go/pkg/plugintk/plugin_type_domain.go +++ b/toolkit/go/pkg/plugintk/plugin_type_domain.go @@ -48,6 +48,9 @@ type DomainCallbacks interface { EncodeData(context.Context, *prototk.EncodeDataRequest) (*prototk.EncodeDataResponse, error) DecodeData(context.Context, *prototk.DecodeDataRequest) (*prototk.DecodeDataResponse, error) RecoverSigner(ctx context.Context, req *prototk.RecoverSignerRequest) (*prototk.RecoverSignerResponse, error) + SendTransaction(ctx context.Context, tx *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) + LocalNodeName(context.Context, *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) + GetStates(ctx context.Context, req *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) } type DomainFactory func(callbacks DomainCallbacks) DomainAPI @@ -245,6 +248,39 @@ func (dp *domainHandler) RecoverSigner(ctx context.Context, req *prototk.Recover }) } +func (dp *domainHandler) SendTransaction(ctx context.Context, req *prototk.SendTransactionRequest) (*prototk.SendTransactionResponse, error) { + res, err := dp.proxy.RequestFromPlugin(ctx, dp.Wrap(&prototk.DomainMessage{ + RequestFromDomain: &prototk.DomainMessage_SendTransaction{ + SendTransaction: req, + }, + })) + return responseToPluginAs(ctx, res, err, func(msg *prototk.DomainMessage_SendTransactionRes) *prototk.SendTransactionResponse { + return msg.SendTransactionRes + }) +} + +func (dp *domainHandler) LocalNodeName(ctx context.Context, req *prototk.LocalNodeNameRequest) (*prototk.LocalNodeNameResponse, error) { + res, err := dp.proxy.RequestFromPlugin(ctx, dp.Wrap(&prototk.DomainMessage{ + RequestFromDomain: &prototk.DomainMessage_LocalNodeName{ + LocalNodeName: req, + }, + })) + return responseToPluginAs(ctx, res, err, func(msg *prototk.DomainMessage_LocalNodeNameRes) *prototk.LocalNodeNameResponse { + return msg.LocalNodeNameRes + }) +} + +func (dp *domainHandler) GetStates(ctx context.Context, req *prototk.GetStatesRequest) (*prototk.GetStatesResponse, error) { + res, err := dp.proxy.RequestFromPlugin(ctx, dp.Wrap(&prototk.DomainMessage{ + RequestFromDomain: &prototk.DomainMessage_GetStates{ + GetStates: req, + }, + })) + return responseToPluginAs(ctx, res, err, func(msg *prototk.DomainMessage_GetStatesRes) *prototk.GetStatesResponse { + return msg.GetStatesRes + }) +} + type DomainAPIFunctions struct { ConfigureDomain func(context.Context, *prototk.ConfigureDomainRequest) (*prototk.ConfigureDomainResponse, error) InitDomain func(context.Context, *prototk.InitDomainRequest) (*prototk.InitDomainResponse, error) diff --git a/toolkit/go/pkg/plugintk/plugin_type_domain_test.go b/toolkit/go/pkg/plugintk/plugin_type_domain_test.go index 8f60e38e5..c5f17d5b7 100644 --- a/toolkit/go/pkg/plugintk/plugin_type_domain_test.go +++ b/toolkit/go/pkg/plugintk/plugin_type_domain_test.go @@ -114,6 +114,45 @@ func TestDomainCallback_RecoverSigner(t *testing.T) { require.NoError(t, err) } +func TestDomainCallback_SendTransaction(t *testing.T) { + ctx, _, _, callbacks, inOutMap, done := setupDomainTests(t) + defer done() + + inOutMap[fmt.Sprintf("%T", &prototk.DomainMessage_SendTransaction{})] = func(dm *prototk.DomainMessage) { + dm.ResponseToDomain = &prototk.DomainMessage_SendTransactionRes{ + SendTransactionRes: &prototk.SendTransactionResponse{}, + } + } + _, err := callbacks.SendTransaction(ctx, &prototk.SendTransactionRequest{}) + require.NoError(t, err) +} + +func TestDomainCallback_LocalNodeName(t *testing.T) { + ctx, _, _, callbacks, inOutMap, done := setupDomainTests(t) + defer done() + + inOutMap[fmt.Sprintf("%T", &prototk.DomainMessage_LocalNodeName{})] = func(dm *prototk.DomainMessage) { + dm.ResponseToDomain = &prototk.DomainMessage_LocalNodeNameRes{ + LocalNodeNameRes: &prototk.LocalNodeNameResponse{}, + } + } + _, err := callbacks.LocalNodeName(ctx, &prototk.LocalNodeNameRequest{}) + require.NoError(t, err) +} + +func TestDomainCallback_GetStates(t *testing.T) { + ctx, _, _, callbacks, inOutMap, done := setupDomainTests(t) + defer done() + + inOutMap[fmt.Sprintf("%T", &prototk.DomainMessage_GetStates{})] = func(dm *prototk.DomainMessage) { + dm.ResponseToDomain = &prototk.DomainMessage_GetStatesRes{ + GetStatesRes: &prototk.GetStatesResponse{}, + } + } + _, err := callbacks.GetStates(ctx, &prototk.GetStatesRequest{}) + require.NoError(t, err) +} + func TestDomainFunction_ConfigureDomain(t *testing.T) { _, exerciser, funcs, _, _, done := setupDomainTests(t) defer done() diff --git a/toolkit/go/pkg/tktypes/rand32.go b/toolkit/go/pkg/tktypes/rand32.go index 1664276ea..3ded663ad 100644 --- a/toolkit/go/pkg/tktypes/rand32.go +++ b/toolkit/go/pkg/tktypes/rand32.go @@ -35,3 +35,7 @@ func RandBytes(count int) []byte { } return b } + +func RandBytes32() Bytes32 { + return Bytes32(RandBytes(32)) +} diff --git a/toolkit/go/pkg/tktypes/rand32_test.go b/toolkit/go/pkg/tktypes/rand32_test.go index 96ca9910e..969f88266 100644 --- a/toolkit/go/pkg/tktypes/rand32_test.go +++ b/toolkit/go/pkg/tktypes/rand32_test.go @@ -38,3 +38,8 @@ func TestRandHex(t *testing.T) { }) } + +func TestRandBytes32(t *testing.T) { + r1 := RandBytes32() + assert.Len(t, r1, 32) +} diff --git a/toolkit/proto/protos/from_domain.proto b/toolkit/proto/protos/from_domain.proto index d9bd58667..07ae07343 100644 --- a/toolkit/proto/protos/from_domain.proto +++ b/toolkit/proto/protos/from_domain.proto @@ -70,6 +70,30 @@ message RecoverSignerResponse { string verifier = 1; } +message SendTransactionRequest { + TransactionInput transaction = 1; +} + +message SendTransactionResponse { + string id = 1; +} + +message LocalNodeNameRequest { +} + +message LocalNodeNameResponse { + string name = 1; +} + +message GetStatesRequest { + string state_query_context = 1; // Must hold a valid state query context to perform a query + string schema_id = 2; // The ID of the schema + repeated string state_ids = 3; // The state IDs +} + +message GetStatesResponse { + repeated StoredState states = 1; +} message StoredState { string id = 1; @@ -88,3 +112,15 @@ message StateLock { StateLockType type = 1; string transaction = 2; } + +message TransactionInput { + enum TransactionType { + PUBLIC = 0; + PRIVATE = 1; + } + TransactionType type = 1; + string from = 2; + string contract_address = 3; + string function_abi_json = 4; + string params_json = 5; +} diff --git a/toolkit/proto/protos/service.proto b/toolkit/proto/protos/service.proto index f9983c4de..5da8ab496 100644 --- a/toolkit/proto/protos/service.proto +++ b/toolkit/proto/protos/service.proto @@ -87,6 +87,9 @@ message DomainMessage { EncodeDataRequest encode_data = 2020; DecodeDataRequest decode_data = 2030; RecoverSignerRequest recover_signer = 2040; + SendTransactionRequest send_transaction = 2050; + LocalNodeNameRequest local_node_name = 2060; + GetStatesRequest get_states = 2070; } oneof response_to_domain { @@ -94,6 +97,9 @@ message DomainMessage { EncodeDataResponse encode_data_res = 2021; DecodeDataResponse decode_data_res = 2031; RecoverSignerResponse recover_signer_res = 2041; + SendTransactionResponse send_transaction_res = 2051; + LocalNodeNameResponse local_node_name_res = 2061; + GetStatesResponse get_states_res = 2071; } }