Commit e5066e33 authored by Wyatt Barnes's avatar Wyatt Barnes Committed by GitHub

Init BindGen E2E tests (#8651)

* Init BindGen unit tests

* Init BindGen E2E tests
parent fd3e7e82
...@@ -512,6 +512,30 @@ jobs: ...@@ -512,6 +512,30 @@ jobs:
command: make && git diff --exit-code command: make && git diff --exit-code
working_directory: op-bindings working_directory: op-bindings
bindgen-remote:
docker:
- image: <<pipeline.parameters.ci_builder_image>>
resource_class: xlarge
steps:
- checkout
- run:
name: bindgen remote bindings
command: make bindgen-remote && git diff --exit-code
working_directory: op-bindings
- notify-failures-on-develop
bindgen-test:
docker:
- image: <<pipeline.parameters.ci_builder_image>>
resource_class: xlarge
steps:
- checkout
- run:
name: bindgen test
command: make test-bindgen-e2e
working_directory: op-bindings
- notify-failures-on-develop
js-lint-test: js-lint-test:
parameters: parameters:
package_name: package_name:
...@@ -1979,3 +2003,16 @@ workflows: ...@@ -1979,3 +2003,16 @@ workflows:
context: context:
- oplabs-gcr - oplabs-gcr
- slack - slack
scheduled-bindgen:
when:
equal: [ build_daily, <<pipeline.schedule.name>> ]
jobs:
- bindgen-remote:
context:
- slack
- oplabs-etherscan
- bindgen-test:
context:
- slack
- oplabs-etherscan
...@@ -80,3 +80,6 @@ clean: ...@@ -80,3 +80,6 @@ clean:
test: test:
go test ./... go test ./...
test-bindgen-e2e:
RUN_E2E=true go test -count=1 ./bindgen/...
...@@ -60,7 +60,8 @@ ...@@ -60,7 +60,8 @@
"name": "Create2Deployer", "name": "Create2Deployer",
"verified": true, "verified": true,
"deployments": { "deployments": {
"eth": "0xF49600926c7109BD66Ab97a2c036bf696e58Dbc2" "eth": "0xF49600926c7109BD66Ab97a2c036bf696e58Dbc2",
"op": "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2"
} }
}, },
{ {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
...@@ -27,7 +27,7 @@ type contractDataClient interface { ...@@ -27,7 +27,7 @@ type contractDataClient interface {
FetchAbi(ctx context.Context, address string) (string, error) FetchAbi(ctx context.Context, address string) (string, error)
FetchDeployedBytecode(ctx context.Context, address string) (string, error) FetchDeployedBytecode(ctx context.Context, address string) (string, error)
FetchDeploymentTxHash(ctx context.Context, address string) (string, error) FetchDeploymentTxHash(ctx context.Context, address string) (string, error)
FetchDeploymentTx(ctx context.Context, txHash string) (etherscan.TxInfo, error) FetchDeploymentTx(ctx context.Context, txHash string) (etherscan.Transaction, error)
} }
type deployments struct { type deployments struct {
......
...@@ -18,7 +18,7 @@ import ( ...@@ -18,7 +18,7 @@ import (
type contractData struct { type contractData struct {
abi string abi string
deployedBin string deployedBin string
deploymentTx etherscan.TxInfo deploymentTx etherscan.Transaction
} }
func (generator *bindGenGeneratorRemote) standardHandler(contractMetadata *remoteContractMetadata) error { func (generator *bindGenGeneratorRemote) standardHandler(contractMetadata *remoteContractMetadata) error {
...@@ -47,8 +47,11 @@ func (generator *bindGenGeneratorRemote) standardHandler(contractMetadata *remot ...@@ -47,8 +47,11 @@ func (generator *bindGenGeneratorRemote) standardHandler(contractMetadata *remot
return err return err
} }
if err := generator.compareBytecodeWithOp(contractMetadata, true, true); err != nil { if err := generator.compareInitBytecodeWithOp(contractMetadata, true); err != nil {
return fmt.Errorf("error comparing contract bytecode for %s: %w", contractMetadata.Name, err) return fmt.Errorf("%s: %w", contractMetadata.Name, err)
}
if err := generator.compareDeployedBytecodeWithOp(contractMetadata, true); err != nil {
return fmt.Errorf("%s: %w", contractMetadata.Name, err)
} }
return generator.writeAllOutputs(contractMetadata, remoteContractMetadataTemplate) return generator.writeAllOutputs(contractMetadata, remoteContractMetadataTemplate)
...@@ -66,10 +69,16 @@ func (generator *bindGenGeneratorRemote) create2DeployerHandler(contractMetadata ...@@ -66,10 +69,16 @@ func (generator *bindGenGeneratorRemote) create2DeployerHandler(contractMetadata
return err return err
} }
// We're not comparing the bytecode for Create2Deployer with deployment on OP, // We're expecting the bytecode for Create2Deployer to not match the deployment on OP,
// because we're predeploying a modified version of Create2Deployer that has not yet been // because we're predeploying a modified version of Create2Deployer that has not yet been
// deployed to OP. // deployed to OP.
// For context: https://github.com/ethereum-optimism/op-geth/pull/126 // For context: https://github.com/ethereum-optimism/op-geth/pull/126
if err := generator.compareInitBytecodeWithOp(contractMetadata, false); err != nil {
return fmt.Errorf("%s: %w", contractMetadata.Name, err)
}
if err := generator.compareDeployedBytecodeWithOp(contractMetadata, false); err != nil {
return fmt.Errorf("%s: %w", contractMetadata.Name, err)
}
return generator.writeAllOutputs(contractMetadata, remoteContractMetadataTemplate) return generator.writeAllOutputs(contractMetadata, remoteContractMetadataTemplate)
} }
...@@ -85,9 +94,6 @@ func (generator *bindGenGeneratorRemote) multiSendHandler(contractMetadata *remo ...@@ -85,9 +94,6 @@ func (generator *bindGenGeneratorRemote) multiSendHandler(contractMetadata *remo
contractMetadata.ABI = fetchedData.abi contractMetadata.ABI = fetchedData.abi
contractMetadata.DeployedBin = fetchedData.deployedBin contractMetadata.DeployedBin = fetchedData.deployedBin
if err = generator.compareDeployedBytecodeWithRpc(contractMetadata, "eth"); err != nil {
return err
}
if err = generator.compareDeployedBytecodeWithRpc(contractMetadata, "op"); err != nil { if err = generator.compareDeployedBytecodeWithRpc(contractMetadata, "op"); err != nil {
return err return err
} }
...@@ -114,8 +120,11 @@ func (generator *bindGenGeneratorRemote) senderCreatorHandler(contractMetadata * ...@@ -114,8 +120,11 @@ func (generator *bindGenGeneratorRemote) senderCreatorHandler(contractMetadata *
// The SenderCreator contract is deployed by EntryPoint, so the transaction data // The SenderCreator contract is deployed by EntryPoint, so the transaction data
// from the deployment transaction is for the entire EntryPoint deployment. // from the deployment transaction is for the entire EntryPoint deployment.
// So, we're manually providing the initialization bytecode and therefore it isn't being compared here // So, we're manually providing the initialization bytecode and therefore it isn't being compared here
if err := generator.compareBytecodeWithOp(contractMetadata, false, true); err != nil { if err := generator.compareInitBytecodeWithOp(contractMetadata, false); err != nil {
return fmt.Errorf("error comparing contract bytecode for %s: %w", contractMetadata.Name, err) return fmt.Errorf("%s: %w", contractMetadata.Name, err)
}
if err := generator.compareDeployedBytecodeWithOp(contractMetadata, true); err != nil {
return fmt.Errorf("%s: %w", contractMetadata.Name, err)
} }
return generator.writeAllOutputs(contractMetadata, remoteContractMetadataTemplate) return generator.writeAllOutputs(contractMetadata, remoteContractMetadataTemplate)
...@@ -128,6 +137,7 @@ func (generator *bindGenGeneratorRemote) permit2Handler(contractMetadata *remote ...@@ -128,6 +137,7 @@ func (generator *bindGenGeneratorRemote) permit2Handler(contractMetadata *remote
} }
contractMetadata.ABI = fetchedData.abi contractMetadata.ABI = fetchedData.abi
contractMetadata.DeployedBin = fetchedData.deployedBin
if contractMetadata.InitBin, err = generator.removeDeploymentSalt(fetchedData.deploymentTx.Input, contractMetadata.DeploymentSalt); err != nil { if contractMetadata.InitBin, err = generator.removeDeploymentSalt(fetchedData.deploymentTx.Input, contractMetadata.DeploymentSalt); err != nil {
return err return err
} }
...@@ -140,10 +150,13 @@ func (generator *bindGenGeneratorRemote) permit2Handler(contractMetadata *remote ...@@ -140,10 +150,13 @@ func (generator *bindGenGeneratorRemote) permit2Handler(contractMetadata *remote
) )
} }
// We're not comparing deployed bytecode because Permit2 has immutable Solidity variables that if err := generator.compareInitBytecodeWithOp(contractMetadata, true); err != nil {
return fmt.Errorf("%s: %w", contractMetadata.Name, err)
}
// We're asserting the deployed bytecode doesn't match, because Permit2 has immutable Solidity variables that
// are dependent on block.chainid // are dependent on block.chainid
if err := generator.compareBytecodeWithOp(contractMetadata, true, false); err != nil { if err := generator.compareDeployedBytecodeWithOp(contractMetadata, false); err != nil {
return fmt.Errorf("error comparing contract bytecode for %s: %w", contractMetadata.Name, err) return fmt.Errorf("%s: %w", contractMetadata.Name, err)
} }
return generator.writeAllOutputs(contractMetadata, permit2MetadataTemplate) return generator.writeAllOutputs(contractMetadata, permit2MetadataTemplate)
...@@ -206,37 +219,77 @@ func (generator *bindGenGeneratorRemote) removeDeploymentSalt(deploymentData, de ...@@ -206,37 +219,77 @@ func (generator *bindGenGeneratorRemote) removeDeploymentSalt(deploymentData, de
return re.ReplaceAllString(deploymentData, ""), nil return re.ReplaceAllString(deploymentData, ""), nil
} }
func (generator *bindGenGeneratorRemote) compareBytecodeWithOp(contractMetadataEth *remoteContractMetadata, compareInitialization, compareDeployment bool) error { func (generator *bindGenGeneratorRemote) compareInitBytecodeWithOp(contractMetadataEth *remoteContractMetadata, initCodeShouldMatch bool) error {
if contractMetadataEth.InitBin == "" {
return fmt.Errorf("no initialization bytecode provided for ETH deployment for comparison")
}
var zeroAddress common.Address
if contractMetadataEth.Deployments.Op == zeroAddress {
return fmt.Errorf("no deployment address on Optimism provided for %s", contractMetadataEth.Name)
}
// Passing false here, because true will retrieve contract's ABI, but we don't need it for bytecode comparison // Passing false here, because true will retrieve contract's ABI, but we don't need it for bytecode comparison
opContractData, err := generator.fetchContractData(false, "op", contractMetadataEth.Deployments.Op.Hex()) opContractData, err := generator.fetchContractData(false, "op", contractMetadataEth.Deployments.Op.Hex())
if err != nil { if err != nil {
return err return err
} }
if compareInitialization { if opContractData.deploymentTx.Input, err = generator.removeDeploymentSalt(opContractData.deploymentTx.Input, contractMetadataEth.DeploymentSalt); err != nil {
if opContractData.deploymentTx.Input, err = generator.removeDeploymentSalt(opContractData.deploymentTx.Input, contractMetadataEth.DeploymentSalt); err != nil { return err
return err }
}
if !strings.EqualFold(contractMetadataEth.InitBin, opContractData.deploymentTx.Input) { initCodeComparison := strings.EqualFold(contractMetadataEth.InitBin, opContractData.deploymentTx.Input)
return fmt.Errorf( if initCodeShouldMatch && !initCodeComparison {
"initialization bytecode on Ethereum doesn't match bytecode on Optimism. contract=%s bytecodeEth=%s bytecodeOp=%s", return fmt.Errorf(
contractMetadataEth.Name, "expected initialization bytecode to match on Ethereum and Optimism, but it doesn't. contract=%s bytecodeEth=%s bytecodeOp=%s",
contractMetadataEth.InitBin, contractMetadataEth.Name,
opContractData.deploymentTx.Input, contractMetadataEth.InitBin,
) opContractData.deploymentTx.Input,
} )
} else if !initCodeShouldMatch && initCodeComparison {
return fmt.Errorf(
"expected initialization bytecode on Ethereum to not match on Optimism, but it did. contract=%s bytecodeEth=%s bytecodeOp=%s",
contractMetadataEth.Name,
contractMetadataEth.InitBin,
opContractData.deploymentTx.Input,
)
} }
if compareDeployment { return nil
if !strings.EqualFold(contractMetadataEth.DeployedBin, opContractData.deployedBin) { }
return fmt.Errorf(
"deployed bytecode on Ethereum doesn't match bytecode on Optimism. contract=%s bytecodeEth=%s bytecodeOp=%s", func (generator *bindGenGeneratorRemote) compareDeployedBytecodeWithOp(contractMetadataEth *remoteContractMetadata, deployedCodeShouldMatch bool) error {
contractMetadataEth.Name, if contractMetadataEth.DeployedBin == "" {
contractMetadataEth.DeployedBin, return fmt.Errorf("no deployed bytecode provided for ETH deployment for comparison")
opContractData.deployedBin, }
)
} var zeroAddress common.Address
if contractMetadataEth.Deployments.Op == zeroAddress {
return fmt.Errorf("no deployment address on Optimism provided for %s", contractMetadataEth.Name)
}
// Passing false here, because true will retrieve contract's ABI, but we don't need it for bytecode comparison
opContractData, err := generator.fetchContractData(false, "op", contractMetadataEth.Deployments.Op.Hex())
if err != nil {
return err
}
deployedCodeComparison := strings.EqualFold(contractMetadataEth.DeployedBin, opContractData.deployedBin)
if deployedCodeShouldMatch && !deployedCodeComparison {
return fmt.Errorf(
"expected deployed bytecode to match on Ethereum and Optimism, but it doesn't. contract=%s bytecodeEth=%s bytecodeOp=%s",
contractMetadataEth.Name,
contractMetadataEth.DeployedBin,
opContractData.deployedBin,
)
} else if !deployedCodeShouldMatch && deployedCodeComparison {
return fmt.Errorf(
"expected deployed bytecode on Ethereum to not match on Optimism, but it does. contract=%s bytecodeEth=%s bytecodeOp=%s",
contractMetadataEth.Name,
contractMetadataEth.DeployedBin,
opContractData.deployedBin,
)
} }
return nil return nil
......
package main package main
import ( import (
"fmt"
"os"
"reflect"
"strings"
"testing" "testing"
"github.com/ethereum-optimism/optimism/op-bindings/etherscan"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestRemoveDeploymentSalt(t *testing.T) { var generator bindGenGeneratorRemote = bindGenGeneratorRemote{}
generator := bindGenGeneratorRemote{}
func configureGenerator(t *testing.T) error {
if os.Getenv("RUN_E2E") == "" {
t.Log("Not running test, RUN_E2E env not set")
t.Skip()
}
generator.contractDataClients.eth = etherscan.NewEthereumClient(os.Getenv("ETHERSCAN_APIKEY_ETH"))
generator.contractDataClients.op = etherscan.NewOptimismClient(os.Getenv("ETHERSCAN_APIKEY_OP"))
var err error
if generator.rpcClients.eth, err = ethclient.Dial(os.Getenv("RPC_URL_ETH")); err != nil {
return fmt.Errorf("error initializing Ethereum client: %w", err)
}
if generator.rpcClients.op, err = ethclient.Dial(os.Getenv("RPC_URL_OP")); err != nil {
return fmt.Errorf("error initializing Optimism client: %w", err)
}
return nil
}
func TestFetchContractData(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range fetchContractDataTests {
t.Run(tt.name, func(t *testing.T) {
contractData, err := generator.fetchContractData(tt.contractVerified, tt.chain, tt.deploymentAddress)
if err != nil {
t.Error(err)
}
if !reflect.DeepEqual(contractData, tt.expectedContractData) {
t.Errorf("Retrieved contract data doesn't match expected. Expected: %s Retrieved: %s", tt.expectedContractData, contractData)
}
})
}
}
func TestFetchContractDataFailures(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range fetchContractDataTestsFailures {
t.Run(tt.name, func(t *testing.T) {
_, err := generator.fetchContractData(tt.contractVerified, tt.chain, tt.deploymentAddress)
if err == nil {
t.Errorf("Expected error: %s but didn't receive it", tt.expectedError)
return
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error: %s Received: %s", tt.expectedError, err)
return
}
})
}
}
func TestRemoveDeploymentSalt(t *testing.T) {
for _, tt := range removeDeploymentSaltTests { for _, tt := range removeDeploymentSaltTests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, _ := generator.removeDeploymentSalt(tt.deploymentData, tt.deploymentSalt) got, _ := generator.removeDeploymentSalt(tt.deploymentData, tt.deploymentSalt)
...@@ -18,8 +83,6 @@ func TestRemoveDeploymentSalt(t *testing.T) { ...@@ -18,8 +83,6 @@ func TestRemoveDeploymentSalt(t *testing.T) {
} }
func TestRemoveDeploymentSaltFailures(t *testing.T) { func TestRemoveDeploymentSaltFailures(t *testing.T) {
generator := bindGenGeneratorRemote{}
for _, tt := range removeDeploymentSaltTestsFailures { for _, tt := range removeDeploymentSaltTestsFailures {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
_, err := generator.removeDeploymentSalt(tt.deploymentData, tt.deploymentSalt) _, err := generator.removeDeploymentSalt(tt.deploymentData, tt.deploymentSalt)
...@@ -27,3 +90,111 @@ func TestRemoveDeploymentSaltFailures(t *testing.T) { ...@@ -27,3 +90,111 @@ func TestRemoveDeploymentSaltFailures(t *testing.T) {
}) })
} }
} }
func TestCompareInitBytecodeWithOp(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range compareInitBytecodeWithOpTests {
t.Run(tt.name, func(t *testing.T) {
err := generator.compareInitBytecodeWithOp(&tt.contractMetadataEth, tt.initCodeShouldMatch)
if err != nil {
t.Error(err)
}
})
}
}
func TestCompareInitBytecodeWithOpFailures(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range compareInitBytecodeWithOpTestsFailures {
t.Run(tt.name, func(t *testing.T) {
err := generator.compareInitBytecodeWithOp(&tt.contractMetadataEth, tt.initCodeShouldMatch)
if err == nil {
t.Errorf("Expected error: %s but didn't receive it", tt.expectedError)
return
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error: %s Received: %s", tt.expectedError, err)
return
}
})
}
}
func TestCompareDeployedBytecodeWithOp(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range compareDeployedBytecodeWithOpTests {
t.Run(tt.name, func(t *testing.T) {
err := generator.compareDeployedBytecodeWithOp(&tt.contractMetadataEth, tt.deployedCodeShouldMatch)
if err != nil {
t.Error(err)
}
})
}
}
func TestCompareDeployedBytecodeWithOpFailures(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range compareDeployedBytecodeWithOpTestsFailures {
t.Run(tt.name, func(t *testing.T) {
err := generator.compareDeployedBytecodeWithOp(&tt.contractMetadataEth, tt.deployedCodeShouldMatch)
if err == nil {
t.Errorf("Expected error: %s but didn't receive it", tt.expectedError)
return
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error: %s Received: %s", tt.expectedError, err)
return
}
})
}
}
func TestCompareDeployedBytecodeWithRpc(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range compareDeployedBytecodeWithRpcTests {
t.Run(tt.name, func(t *testing.T) {
err := generator.compareDeployedBytecodeWithRpc(&tt.contractMetadataEth, tt.chain)
if err != nil {
t.Error(err)
}
})
}
}
func TestCompareDeployedBytecodeWithRpcFailures(t *testing.T) {
if err := configureGenerator(t); err != nil {
t.Error(err)
}
for _, tt := range compareDeployedBytecodeWithRpcTestsFailures {
t.Run(tt.name, func(t *testing.T) {
err := generator.compareDeployedBytecodeWithRpc(&tt.contractMetadataEth, tt.chain)
if err == nil {
t.Errorf("Expected error: %s but didn't receive it", tt.expectedError)
return
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error: %s Received: %s", tt.expectedError, err)
return
}
})
}
}
...@@ -30,10 +30,10 @@ type rpcResponse struct { ...@@ -30,10 +30,10 @@ type rpcResponse struct {
Result json.RawMessage `json:"result"` Result json.RawMessage `json:"result"`
} }
type TxInfo struct { type Transaction struct {
TxHash string `json:"txHash"` Hash string `json:"hash"`
To string `json:"to"` Input string `json:"input"`
Input string `json:"input"` To string `json:"to"`
} }
const apiMaxRetries = 3 const apiMaxRetries = 3
...@@ -174,7 +174,9 @@ func (c *client) FetchDeploymentTxHash(ctx context.Context, address string) (str ...@@ -174,7 +174,9 @@ func (c *client) FetchDeploymentTxHash(ctx context.Context, address string) (str
return "", err return "", err
} }
var results []TxInfo var results []struct {
Hash string `json:"txHash"`
}
err = json.Unmarshal(response.Result, &results) err = json.Unmarshal(response.Result, &results)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to unmarshal API response as []txInfo: %w", err) return "", fmt.Errorf("failed to unmarshal API response as []txInfo: %w", err)
...@@ -184,28 +186,28 @@ func (c *client) FetchDeploymentTxHash(ctx context.Context, address string) (str ...@@ -184,28 +186,28 @@ func (c *client) FetchDeploymentTxHash(ctx context.Context, address string) (str
return "", fmt.Errorf("API response result is an empty array") return "", fmt.Errorf("API response result is an empty array")
} }
return results[0].TxHash, nil return results[0].Hash, nil
} }
func (c *client) FetchDeploymentTx(ctx context.Context, txHash string) (TxInfo, error) { func (c *client) FetchDeploymentTx(ctx context.Context, txHash string) (Transaction, error) {
params := url.Values{} params := url.Values{}
params.Set("txHash", txHash) params.Set("txHash", txHash)
params.Set("tag", "latest") params.Set("tag", "latest")
url := constructUrl(c.baseUrl, "eth_getTransactionByHash", "proxy", params) url := constructUrl(c.baseUrl, "eth_getTransactionByHash", "proxy", params)
response, err := c.fetchEtherscanRpc(ctx, url) response, err := c.fetchEtherscanRpc(ctx, url)
if err != nil { if err != nil {
return TxInfo{}, err return Transaction{}, err
} }
resultBytes, err := json.Marshal(response.Result) resultBytes, err := json.Marshal(response.Result)
if err != nil { if err != nil {
return TxInfo{}, fmt.Errorf("failed to marshal Result into JSON: %w", err) return Transaction{}, fmt.Errorf("failed to marshal Result into JSON: %w", err)
} }
var tx TxInfo var tx Transaction
err = json.Unmarshal(resultBytes, &tx) err = json.Unmarshal(resultBytes, &tx)
if err != nil { if err != nil {
return TxInfo{}, fmt.Errorf("API response result is not expected txInfo struct: %w", err) return Transaction{}, fmt.Errorf("API response result is not expected txInfo struct: %w", err)
} }
return tx, nil return tx, nil
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment