diff --git a/.github/workflows/cre-regression-system-tests.yaml b/.github/workflows/cre-regression-system-tests.yaml index 8f562d26469..d033631f789 100644 --- a/.github/workflows/cre-regression-system-tests.yaml +++ b/.github/workflows/cre-regression-system-tests.yaml @@ -90,7 +90,8 @@ jobs: test_names=$(grep -rh -oP '^func \K(Test|Example)[^(]+' system-tests/tests/regression/cre/*_test.go) per_test_configs='{ - "Test_CRE_V2_Stellar_Regression": "configs/workflow-gateway-don-stellar.toml" + "Test_CRE_V2_Stellar_Regression": "configs/workflow-gateway-don-stellar.toml", + "Test_CRE_V2_Solana_Regression": "configs/workflow-don-solana.toml" }' tests=$(echo "$test_names" | jq -c -R -s \ diff --git a/go.md b/go.md index eb3c2f58743..45e9aaf0b6a 100644 --- a/go.md +++ b/go.md @@ -517,6 +517,9 @@ flowchart LR chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/http chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/httpaction-negative + chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative + chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/solana/solread-negative + chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/solana/solwrite-negative chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/evm/evmread @@ -545,6 +548,14 @@ flowchart LR chainlink/system-tests/tests/regression/cre/httpaction-negative --> cre-sdk-go/capabilities/networking/http chainlink/system-tests/tests/regression/cre/httpaction-negative --> cre-sdk-go/capabilities/scheduler/cron click chainlink/system-tests/tests/regression/cre/httpaction-negative href "https://github.com/smartcontractkit/chainlink" + chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative --> cre-sdk-go/capabilities/blockchain/solana + click chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative href "https://github.com/smartcontractkit/chainlink" + chainlink/system-tests/tests/regression/cre/solana/solread-negative --> cre-sdk-go/capabilities/blockchain/solana + chainlink/system-tests/tests/regression/cre/solana/solread-negative --> cre-sdk-go/capabilities/scheduler/cron + click chainlink/system-tests/tests/regression/cre/solana/solread-negative href "https://github.com/smartcontractkit/chainlink" + chainlink/system-tests/tests/regression/cre/solana/solwrite-negative --> cre-sdk-go/capabilities/blockchain/solana + chainlink/system-tests/tests/regression/cre/solana/solwrite-negative --> cre-sdk-go/capabilities/scheduler/cron + click chainlink/system-tests/tests/regression/cre/solana/solwrite-negative href "https://github.com/smartcontractkit/chainlink" chainlink/system-tests/tests/smoke/cre/aptos/aptosread --> cre-sdk-go/capabilities/blockchain/aptos chainlink/system-tests/tests/smoke/cre/aptos/aptosread --> cre-sdk-go/capabilities/scheduler/cron click chainlink/system-tests/tests/smoke/cre/aptos/aptosread href "https://github.com/smartcontractkit/chainlink" @@ -665,6 +676,9 @@ flowchart LR chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative chainlink/system-tests/tests/regression/cre/http chainlink/system-tests/tests/regression/cre/httpaction-negative + chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative + chainlink/system-tests/tests/regression/cre/solana/solread-negative + chainlink/system-tests/tests/regression/cre/solana/solwrite-negative chainlink/system-tests/tests/smoke/cre/aptos/aptosread chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod index 69c75a62923..b488fd10f77 100644 --- a/system-tests/tests/go.mod +++ b/system-tests/tests/go.mod @@ -39,6 +39,12 @@ replace github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/ replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/solana/solwrite => ./smoke/cre/solana/solwrite +replace github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solwrite-negative => ./regression/cre/solana/solwrite-negative + +replace github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solread-negative => ./regression/cre/solana/solread-negative + +replace github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative => ./regression/cre/solana/sollogtrigger-negative + replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/solana/sollogtrigger => ./smoke/cre/solana/sollogtrigger replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/vaultsecret => ./smoke/cre/vaultsecret @@ -88,6 +94,9 @@ require ( github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/evmwrite-negative v0.0.0-20251008094352-f74459c46e8c github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/http v0.0.0-20251008094352-f74459c46e8c + github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative v0.0.0-00010101000000-000000000000 + github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solread-negative v0.0.0-00010101000000-000000000000 + github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solwrite-negative v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/evmread v0.0.0-20251008094352-f74459c46e8c diff --git a/system-tests/tests/regression/cre/solana/sollogtrigger-negative/config/config.go b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/config/config.go new file mode 100644 index 00000000000..a47e3c9623c --- /dev/null +++ b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/config/config.go @@ -0,0 +1,19 @@ +package config + +import solanago "github.com/gagliardetto/solana-go" + +// Config is the configuration of the Solana log trigger negative workflow. +// Every case builds a log trigger filter that the Solana capability is expected to reject when +// the workflow engine registers the trigger. +type Config struct { + ChainSelector uint64 + // FunctionToTest must match one of the switch-case labels in sollogtrigger-negative/main.go + FunctionToTest string + // InvalidInput is interpreted based on FunctionToTest: + // - addresses: hex-encoded raw bytes of any length (an empty string means "no bytes") + // Cases whose invalid input is a missing field ignore it. + InvalidInput string + // ProgramID is a well-formed program address, used by the cases whose invalid input is + // something other than the address itself. + ProgramID solanago.PublicKey +} diff --git a/system-tests/tests/regression/cre/solana/sollogtrigger-negative/go.mod b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/go.mod new file mode 100644 index 00000000000..2dd423cb7d0 --- /dev/null +++ b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/go.mod @@ -0,0 +1,48 @@ +module github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative + +go 1.26.5 + +require ( + github.com/gagliardetto/solana-go v1.14.0 + github.com/smartcontractkit/cre-sdk-go v1.11.0 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260612142557-01f4db8d7d47 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.1 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/ethereum/go-ethereum v1.17.2 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/gagliardetto/binary v0.8.0 // indirect + github.com/gagliardetto/treeout v0.1.4 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260609153034-c8423a41ef9a // indirect + github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.mongodb.org/mongo-driver v1.17.7 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace github.com/mattn/go-isatty => github.com/Unheilbar/go-isatty v0.0.2 // original isatty doesn't support wasim diff --git a/system-tests/tests/regression/cre/solana/sollogtrigger-negative/go.sum b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/go.sum new file mode 100644 index 00000000000..e8da8ee09bb --- /dev/null +++ b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/go.sum @@ -0,0 +1,151 @@ +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/Unheilbar/go-isatty v0.0.2 h1:bTDPH7NsGwWHyfy57ZUT5Q+V27yx7+/F5QLipGZrHvg= +github.com/Unheilbar/go-isatty v0.0.2/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw= +github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260609153034-c8423a41ef9a h1:alnfvQgCKPFqsfijZQnr6Sbus2GT5YZ9BT/KzVrbszE= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260609153034-c8423a41ef9a/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/cre-sdk-go v1.11.0 h1:E3MG0j8O9qDv6lDz71HPD3/WRKh/PX2/hfxO1+9YL2w= +github.com/smartcontractkit/cre-sdk-go v1.11.0/go.mod h1:8SDE/e+eDAFpbRjRyKnIalUkQk9BcNbo2aLnda9BM48= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260612142557-01f4db8d7d47 h1:mJBsvYx40drsXCOqPnDoGCgnpEzboOyO6SRL6u4C1Ts= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260612142557-01f4db8d7d47/go.mod h1:3Oja7Gviyrb8dtld04krKCsTQ41RP/3hpCmHXjZf31I= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= +go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/system-tests/tests/regression/cre/solana/sollogtrigger-negative/main.go b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/main.go new file mode 100644 index 00000000000..6b633e6f8eb --- /dev/null +++ b/system-tests/tests/regression/cre/solana/sollogtrigger-negative/main.go @@ -0,0 +1,164 @@ +//go:build wasip1 + +package main + +import ( + "encoding/hex" + "fmt" + "log/slog" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + + "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative/config" +) + +// Function names, they must literally match the `functionToTest` values used by the +// regression test (system-tests/tests/regression/cre/solana_regression_test.go). +const ( + logTriggerInvalidAddress = "LogTrigger - invalid address" + logTriggerEmptyEventName = "LogTrigger - empty event name" + logTriggerEmptyFilterName = "LogTrigger - empty filter name" + logTriggerEmptyIDL = "LogTrigger - empty IDL" + logTriggerConflictingSubkeys = "LogTrigger - conflicting subkey filters" + logTriggerInvalidCPIDestAddres = "LogTrigger - invalid CPI destination address" +) + +// anchorCPIMethodName is the method name Anchor uses for CPI-emitted events. +const anchorCPIMethodName = "anchor:event" + +// testEventIDL is the Anchor IDL of the log_read_test program, copied from the generated bindings +// used by the Solana log trigger smoke test +// (system-tests/tests/smoke/cre/solana/sollogtrigger/contracts/solana/src/generated/log_read_test). +// The negative cases build their filters by hand, so they need the IDL as a plain value: every +// case keeps it valid except the one that asserts an empty IDL is rejected. +// +// It lives in this file, and not in a second file of the package, because workflows are compiled +// with `go build main.go` - a sibling file in package main would not be part of the build. +const testEventIDL = "{\"address\":\"J1zQwrBNBngz26jRPNWsUSZMHJwBwpkoDitXRV95LdK4\",\"metadata\":{\"name\":\"log_read_test\",\"version\":\"0.1.0\",\"spec\":\"0.1.0\",\"description\":\"Created with Anchor\"},\"instructions\":[{\"name\":\"create_log\",\"discriminator\":[215,95,248,114,153,204,208,48],\"accounts\":[{\"name\":\"authority\",\"signer\":true},{\"name\":\"system_program\"}],\"args\":[{\"name\":\"value\",\"type\":\"u64\"}]},{\"name\":\"create_log_cpi\",\"discriminator\":[101,207,236,250,214,98,173,146],\"accounts\":[{\"name\":\"authority\",\"signer\":true},{\"name\":\"system_program\"},{\"name\":\"event_authority\"},{\"name\":\"program\"}],\"args\":[{\"name\":\"value\",\"type\":\"u64\"}]},{\"name\":\"create_truncated_log\",\"discriminator\":[133,74,116,132,80,11,241,64],\"accounts\":[{\"name\":\"authority\",\"signer\":true},{\"name\":\"system_program\"}],\"args\":[{\"name\":\"value\",\"type\":\"u64\"}]}],\"events\":[{\"name\":\"TestEvent\",\"discriminator\":[28,52,39,105,8,210,91,9]}],\"types\":[{\"name\":\"TestEvent\",\"type\":{\"kind\":\"struct\",\"fields\":[{\"name\":\"str_val\",\"type\":\"string\"},{\"name\":\"u64_value\",\"type\":\"u64\"}]}}]}" + +// testEventName is the event declared by the IDL above. +const testEventName = "TestEvent" + +const validFilterName = "solana-negative-test-event-filter" + +func main() { + wasm.NewRunner(parseConfig).Run(RunSolanaLogTriggerNegativeWorkflow) +} + +func parseConfig(b []byte) (config.Config, error) { + var wfCfg config.Config + if err := yaml.Unmarshal(b, &wfCfg); err != nil { + return config.Config{}, fmt.Errorf("error unmarshalling config: %w", err) + } + return wfCfg, nil +} + +// RunSolanaLogTriggerNegativeWorkflow registers a log trigger whose filter is invalid, so the +// workflow engine is expected to fail while initialising and onLogTrigger is never called. +func RunSolanaLogTriggerNegativeWorkflow(wfCfg config.Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[config.Config], error) { + logger.Info("RunSolanaLogTriggerNegativeWorkflow called", "functionToTest", wfCfg.FunctionToTest) + + filter, err := buildFilter(wfCfg) + if err != nil { + logger.Error(fmt.Sprintf("failed to build log trigger filter for '%s': %v", wfCfg.FunctionToTest, err)) + return nil, fmt.Errorf("failed to build log trigger filter for '%s': %w", wfCfg.FunctionToTest, err) + } + + logger.Info(fmt.Sprintf( + "FilterLogTriggerRequest content: Name=%q Address=%s EventName=%q ContractIdlJson=%d bytes Subkeys=%d CpiFilterConfig=%s", + filter.Name, + hex.EncodeToString(filter.Address), + filter.EventName, + len(filter.ContractIdlJson), + len(filter.Subkeys), + formatCPIFilterConfig(filter.CpiFilterConfig), + )) + + return cre.Workflow[config.Config]{ + cre.Handler( + solana.LogTrigger(wfCfg.ChainSelector, filter), + onLogTrigger, + ), + }, nil +} + +// buildFilter returns an intentionally invalid log trigger filter for the selected case. Every +// field other than the one under test stays valid, so that the capability rejects the filter for +// the reason the case is about. +func buildFilter(wfCfg config.Config) (*solana.FilterLogTriggerRequest, error) { + filter := &solana.FilterLogTriggerRequest{ + Name: validFilterName, + Address: wfCfg.ProgramID.Bytes(), + EventName: testEventName, + ContractIdlJson: []byte(testEventIDL), + } + + switch wfCfg.FunctionToTest { + case logTriggerInvalidAddress: + address, err := decodeHex(wfCfg.InvalidInput) + if err != nil { + return nil, fmt.Errorf("failed to decode address '%s': %w", wfCfg.InvalidInput, err) + } + filter.Address = address + + case logTriggerEmptyEventName: + filter.EventName = "" + + case logTriggerEmptyFilterName: + filter.Name = "" + + case logTriggerEmptyIDL: + filter.ContractIdlJson = nil + + case logTriggerConflictingSubkeys: + // Two equality filters with different values on the same subkey can never both hold. + filter.Subkeys = []*solana.SubkeyConfig{ + { + Path: []string{"u64_value"}, + Comparers: []*solana.ValueComparator{ + {Value: []byte{1}, Operator: solana.ComparisonOperator_COMPARISON_OPERATOR_EQ}, + {Value: []byte{2}, Operator: solana.ComparisonOperator_COMPARISON_OPERATOR_EQ}, + }, + }, + } + + case logTriggerInvalidCPIDestAddres: + destAddress, err := decodeHex(wfCfg.InvalidInput) + if err != nil { + return nil, fmt.Errorf("failed to decode CPI destination address '%s': %w", wfCfg.InvalidInput, err) + } + filter.CpiFilterConfig = &solana.CPIFilterConfig{ + DestAddress: destAddress, + MethodName: []byte(anchorCPIMethodName), + } + + default: + return nil, fmt.Errorf("the provided name for function to test in regression Solana LogTrigger Workflow did not match any known functions: %s", wfCfg.FunctionToTest) + } + + return filter, nil +} + +// onLogTrigger should never be called: registering the trigger is expected to fail first. +func onLogTrigger(wfCfg config.Config, runtime cre.Runtime, payload *solana.Log) (string, error) { + runtime.Logger().Error("onLogTrigger should not be called - trigger registration should have failed", "functionToTest", wfCfg.FunctionToTest) + return "", fmt.Errorf("onLogTrigger should not be called - trigger registration should have failed for '%s'", wfCfg.FunctionToTest) +} + +func formatCPIFilterConfig(cpiFilterConfig *solana.CPIFilterConfig) string { + if cpiFilterConfig == nil { + return "" + } + return fmt.Sprintf("{destAddress: %s, methodName: %s}", hex.EncodeToString(cpiFilterConfig.DestAddress), cpiFilterConfig.MethodName) +} + +// decodeHex decodes a hex-encoded byte string of any length. An empty string decodes to no bytes, +// which reaches the capability as a nil field. +func decodeHex(s string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(s, "0x")) +} diff --git a/system-tests/tests/regression/cre/solana/solread-negative/config/config.go b/system-tests/tests/regression/cre/solana/solread-negative/config/config.go new file mode 100644 index 00000000000..6bf1a974c4b --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solread-negative/config/config.go @@ -0,0 +1,40 @@ +package config + +// Outcome is what a case expects the Solana capability to answer with. +type Outcome int + +const ( + // OutcomeError expects the call to fail with an error containing ExpectedError. It is used + // for malformed requests, which the capability rejects before they reach the RPC. + OutcomeError Outcome = iota + // OutcomeErrorOrEmpty expects either an error or an empty response. It is used for requests + // that are well-formed but point at something that does not exist, where answering with + // "nothing" is as correct as failing - only returning real data is a failure. + OutcomeErrorOrEmpty +) + +// Case is a single negative read case. A batch of them runs in one workflow execution, which is +// what keeps the suite from paying the workflow compile/deploy/trigger cost per case. +type Case struct { + Name string + FunctionToTest string + // InvalidInput is interpreted based on FunctionToTest: + // - account/program keys and transaction signatures: hex-encoded raw bytes of any length + // (an empty string means "no bytes", which reaches the capability as a nil field) + // - slots, commitment/encoding enum values and batch sizes: decimal string + // - encoded messages: passed through verbatim + InvalidInput string + Outcome Outcome + // ExpectedError is the substring the capability error must contain. Only used by OutcomeError. + ExpectedError string +} + +// Config is the configuration of the Solana read negative workflow. +type Config struct { + ChainSelector uint64 + // BatchName identifies the batch in the workflow logs the test asserts on. + BatchName string + // Cases all run in a single execution, so their number must stay within + // PerWorkflow.ChainRead.CallLimit - the engine counts rejected calls too. + Cases []Case +} diff --git a/system-tests/tests/regression/cre/solana/solread-negative/go.mod b/system-tests/tests/regression/cre/solana/solread-negative/go.mod new file mode 100644 index 00000000000..e0702865ba8 --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solread-negative/go.mod @@ -0,0 +1,25 @@ +module github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solread-negative + +go 1.26.5 + +require ( + github.com/smartcontractkit/cre-sdk-go v1.9.0-capdev.1.0.20260604190002-3650885b31f6 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260604190002-3650885b31f6 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/ethereum/go-ethereum v1.17.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/sys v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/system-tests/tests/regression/cre/solana/solread-negative/go.sum b/system-tests/tests/regression/cre/solana/solread-negative/go.sum new file mode 100644 index 00000000000..c2db43cb729 --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solread-negative/go.sum @@ -0,0 +1,45 @@ +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f h1:t+OoYaXLdH0WHK2pbWKjTSnSQa5JBQD1+gf0yISYfQk= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/cre-sdk-go v1.9.0-capdev.1.0.20260604190002-3650885b31f6 h1:Wo0LtcNVLrJFTC0Uq1t5r+DbxdM7CwHkBU/ieCLtMTA= +github.com/smartcontractkit/cre-sdk-go v1.9.0-capdev.1.0.20260604190002-3650885b31f6/go.mod h1:B9bBug58zdooG8ylplFLDnto1hapkCYyVO+FZar0hTM= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260604190002-3650885b31f6 h1:/HwQoLiEGEeoxw72LXQKRZMgTvnG0n3nG4C92/HfCsc= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260604190002-3650885b31f6/go.mod h1:p2624jtDB4jXiRlkWn+Qqh9dtPM3gDChypBuOpPK35E= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 h1:qBZ4y6qlTOynSpU1QAi2Fgr3tUZQ332b6hit9EVZqkk= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0/go.mod h1:Rzhy75vD3FqQo/SV6lypnxIwjWac6IOWzI5BYj3tYMU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/system-tests/tests/regression/cre/solana/solread-negative/main.go b/system-tests/tests/regression/cre/solana/solread-negative/main.go new file mode 100644 index 00000000000..2dee5b1d1e3 --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solread-negative/main.go @@ -0,0 +1,443 @@ +//go:build wasip1 + +package main + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "log/slog" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana" + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + + "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solread-negative/config" +) + +// Function names, they must literally match the `functionToTest` values used by the +// regression test (system-tests/tests/regression/cre/solana_regression_test.go). +const ( + getAccountInfoInvalidAccount = "GetAccountInfoWithOpts - invalid account" + getAccountInfoNonExistentAccount = "GetAccountInfoWithOpts - non-existent account" + getAccountInfoInvalidCommitment = "GetAccountInfoWithOpts - invalid commitment" + getAccountInfoInvalidEncoding = "GetAccountInfoWithOpts - invalid encoding" + getBalanceInvalidAddress = "GetBalance - invalid address" + getBalanceNonExistentAccount = "GetBalance - non-existent account" + getMultipleAccountsInvalidAccount = "GetMultipleAccountsWithOpts - invalid account" + getMultipleAccountsTooManyItems = "GetMultipleAccountsWithOpts - too many accounts" + getProgramAccountsInvalidProgram = "GetProgramAccounts - invalid program" + getProgramAccountsNonProgram = "GetProgramAccounts - non-program account" + getBlockInvalidSlot = "GetBlock - invalid slot" + getSlotHeightInvalidCommitment = "GetSlotHeight - invalid commitment" + getTransactionInvalidSignature = "GetTransaction - invalid signature" + getTransactionUnknownSignature = "GetTransaction - unknown signature" + getSignatureStatusesInvalidSig = "GetSignatureStatuses - invalid signature" + getSignatureStatusesTooManyItems = "GetSignatureStatuses - too many signatures" + getFeeForMessageInvalidMessage = "GetFeeForMessage - invalid message" +) + +// maxReadCallsPerExecution mirrors PerWorkflow.ChainRead.CallLimit. The engine counts every +// attempted chain read, including the ones the capability rejects, so a batch that goes over the +// limit would start failing with a call-limit error instead of the error each case is about. +const maxReadCallsPerExecution = 15 + +func main() { + wasm.NewRunner(parseConfig).Run(RunSolanaReadNegativeWorkflow) +} + +func parseConfig(b []byte) (config.Config, error) { + var wfCfg config.Config + if err := yaml.Unmarshal(b, &wfCfg); err != nil { + return config.Config{}, fmt.Errorf("error unmarshalling config: %w", err) + } + return wfCfg, nil +} + +func RunSolanaReadNegativeWorkflow(wfCfg config.Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[config.Config], error) { + return cre.Workflow[config.Config]{ + cre.Handler( + cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}), + onSolanaReadTrigger, + ), + }, nil +} + +// outcome is what a single case got back from the capability. +type outcome struct { + // empty reports whether the capability answered successfully but with nothing in it. + empty bool + // err is the capability error, nil when the call succeeded. + err error +} + +// onSolanaReadTrigger runs every case of the batch in one execution and verifies each of them +// in place, so that the test only has to wait for a single "batch passed" log. +func onSolanaReadTrigger(wfCfg config.Config, runtime cre.Runtime, payload *cron.Payload) (any, error) { + runtime.Logger().Info("onSolanaReadTrigger called", "batch", wfCfg.BatchName, "cases", len(wfCfg.Cases), "payload", payload) + + if len(wfCfg.Cases) == 0 { + return nil, fmt.Errorf("batch '%s' has no cases to run", wfCfg.BatchName) + } + if len(wfCfg.Cases) > maxReadCallsPerExecution { + return nil, fmt.Errorf("batch '%s' has %d cases, which is over the %d chain reads a single execution is allowed", + wfCfg.BatchName, len(wfCfg.Cases), maxReadCallsPerExecution) + } + + client := solana.Client{ChainSelector: wfCfg.ChainSelector} + + for _, testCase := range wfCfg.Cases { + result, err := runCase(client, runtime, testCase) + if err != nil { + runtime.Logger().Error(fmt.Sprintf("Solana read negative batch '%s' could not run case '%s' (%s): %v", + wfCfg.BatchName, testCase.Name, testCase.FunctionToTest, err)) + return nil, fmt.Errorf("could not run case '%s' (%s): %w", testCase.Name, testCase.FunctionToTest, err) + } + + if err := verify(testCase, result); err != nil { + runtime.Logger().Error(fmt.Sprintf("Solana read negative batch '%s' failed on case '%s' (%s): %v", + wfCfg.BatchName, testCase.Name, testCase.FunctionToTest, err)) + return nil, fmt.Errorf("case '%s' (%s) failed: %w", testCase.Name, testCase.FunctionToTest, err) + } + + runtime.Logger().Info(fmt.Sprintf("case '%s' (%s) passed: %s", testCase.Name, testCase.FunctionToTest, describe(result))) + } + + // The test waits for this line, and for this line only. + runtime.Logger().Info(fmt.Sprintf("Solana read negative batch '%s' passed (%d cases)", wfCfg.BatchName, len(wfCfg.Cases))) + return fmt.Sprintf("batch '%s' passed", wfCfg.BatchName), nil +} + +// verify checks what the capability answered against what the case expects. The error it returns +// describes the mismatch, so a failing batch names the case and what it actually got. +func verify(testCase config.Case, result outcome) error { + switch testCase.Outcome { + case config.OutcomeError: + if result.err == nil { + return fmt.Errorf("expected an error containing %q, got a successful response", testCase.ExpectedError) + } + if !strings.Contains(result.err.Error(), testCase.ExpectedError) { + return fmt.Errorf("expected an error containing %q, got: %v", testCase.ExpectedError, result.err) + } + case config.OutcomeErrorOrEmpty: + if result.err == nil && !result.empty { + return fmt.Errorf("expected an error or an empty response, got data back") + } + default: + return fmt.Errorf("unknown outcome: %d", testCase.Outcome) + } + + return nil +} + +func describe(result outcome) string { + if result.err != nil { + return fmt.Sprintf("got expected error: %v", result.err) + } + return "got expected empty response" +} + +// runCase performs the capability call of a single case. The returned error means the case could +// not be run at all (for example its input could not be decoded); the capability's own error is +// reported through the outcome instead, because for these cases it is the expected result. +func runCase(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + runtime.Logger().Info("running case", "name", testCase.Name, "functionToTest", testCase.FunctionToTest, "invalidInput", testCase.InvalidInput) + + switch testCase.FunctionToTest { + case getAccountInfoInvalidAccount, getAccountInfoNonExistentAccount: + return getAccountInfo(client, runtime, testCase) + case getAccountInfoInvalidCommitment: + return getAccountInfoWithInvalidCommitment(client, runtime, testCase) + case getAccountInfoInvalidEncoding: + return getAccountInfoWithInvalidEncoding(client, runtime, testCase) + case getBalanceInvalidAddress, getBalanceNonExistentAccount: + return getBalance(client, runtime, testCase) + case getMultipleAccountsInvalidAccount: + return getMultipleAccountsWithInvalidAccount(client, runtime, testCase) + case getMultipleAccountsTooManyItems: + return getMultipleAccountsOverBatchLimit(client, runtime, testCase) + case getProgramAccountsInvalidProgram, getProgramAccountsNonProgram: + return getProgramAccounts(client, runtime, testCase) + case getBlockInvalidSlot: + return getBlock(client, runtime, testCase) + case getSlotHeightInvalidCommitment: + return getSlotHeightWithInvalidCommitment(client, runtime, testCase) + case getTransactionInvalidSignature, getTransactionUnknownSignature: + return getTransaction(client, runtime, testCase) + case getSignatureStatusesInvalidSig: + return getSignatureStatusesWithInvalidSignature(client, runtime, testCase) + case getSignatureStatusesTooManyItems: + return getSignatureStatusesOverBatchLimit(client, runtime, testCase) + case getFeeForMessageInvalidMessage: + return getFeeForMessage(client, runtime, testCase) + default: + return outcome{}, fmt.Errorf("the provided name for function to test in regression Solana Read Workflow did not match any known functions: %s", testCase.FunctionToTest) + } +} + +// getAccountInfo reads an account that is either not a 32-byte public key or was never created. +func getAccountInfo(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + account, err := decodeHex(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode account '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetAccountInfoWithOpts(runtime, &solana.GetAccountInfoWithOptsRequest{ + Account: account, + Opts: defaultAccountInfoOpts(), + }).Await() + + return outcome{empty: reply == nil || reply.Value == nil, err: err}, nil +} + +// getAccountInfoWithInvalidCommitment sends a commitment level the capability does not know. +func getAccountInfoWithInvalidCommitment(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + commitment, err := parseInt32(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode commitment '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetAccountInfoWithOpts(runtime, &solana.GetAccountInfoWithOptsRequest{ + Account: validAccount(), + Opts: &solana.GetAccountInfoOpts{ + Encoding: solana.EncodingType_ENCODING_TYPE_JSON_PARSED, + Commitment: solana.CommitmentType(commitment), + }, + }).Await() + + return outcome{empty: reply == nil || reply.Value == nil, err: err}, nil +} + +// getAccountInfoWithInvalidEncoding sends an encoding the capability does not know. +func getAccountInfoWithInvalidEncoding(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + encoding, err := parseInt32(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode encoding '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetAccountInfoWithOpts(runtime, &solana.GetAccountInfoWithOptsRequest{ + Account: validAccount(), + Opts: &solana.GetAccountInfoOpts{ + Encoding: solana.EncodingType(encoding), + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + }, + }).Await() + + return outcome{empty: reply == nil || reply.Value == nil, err: err}, nil +} + +// getBalance reads the balance of an address that is either not a 32-byte public key or was +// never funded - Solana reports zero lamports for the latter rather than failing. +func getBalance(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + address, err := decodeHex(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode address '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetBalance(runtime, &solana.GetBalanceRequest{ + Addr: address, + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + }).Await() + + return outcome{empty: reply == nil || reply.Value == 0, err: err}, nil +} + +// getMultipleAccountsWithInvalidAccount puts a malformed key into an otherwise valid batch. +func getMultipleAccountsWithInvalidAccount(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + account, err := decodeHex(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode account '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetMultipleAccountsWithOpts(runtime, &solana.GetMultipleAccountsWithOptsRequest{ + Accounts: [][]byte{account, validAccount()}, + Opts: defaultMultipleAccountsOpts(), + }).Await() + + return outcome{empty: reply == nil || len(reply.Value) == 0, err: err}, nil +} + +// getMultipleAccountsOverBatchLimit asks for more accounts than +// PerWorkflow.ChainRead.Solana.BatchItemLimit allows. Every key in the batch is valid, so the +// request can only be rejected by the batch limiter. +func getMultipleAccountsOverBatchLimit(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + count, err := strconv.Atoi(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode account count '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetMultipleAccountsWithOpts(runtime, &solana.GetMultipleAccountsWithOptsRequest{ + Accounts: generateAccounts(count), + Opts: defaultMultipleAccountsOpts(), + }).Await() + + return outcome{empty: reply == nil || len(reply.Value) == 0, err: err}, nil +} + +// getProgramAccounts reads accounts owned by a key that is either not a public key at all or is +// not a program, in which case nothing is owned under it. +func getProgramAccounts(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + program, err := decodeHex(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode program '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetProgramAccounts(runtime, &solana.GetProgramAccountsRequest{ + Program: program, + Opts: &solana.GetProgramAccountsOpts{ + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + }, + }).Await() + + return outcome{empty: reply == nil || len(reply.Value) == 0, err: err}, nil +} + +// getBlock reads a slot the local validator has never produced. +func getBlock(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + slot, err := strconv.ParseUint(testCase.InvalidInput, 10, 64) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode slot '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetBlock(runtime, &solana.GetBlockRequest{ + Slot: slot, + Opts: &solana.GetBlockOpts{ + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + }, + }).Await() + + return outcome{empty: reply == nil || len(reply.Blockhash) == 0, err: err}, nil +} + +// getSlotHeightWithInvalidCommitment sends a commitment level the capability does not know. +func getSlotHeightWithInvalidCommitment(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + commitment, err := parseInt32(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode commitment '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetSlotHeight(runtime, &solana.GetSlotHeightRequest{ + Commitment: solana.CommitmentType(commitment), + }).Await() + + return outcome{empty: reply == nil || reply.Height == 0, err: err}, nil +} + +// getTransaction reads a transaction by a signature that is either not 64 bytes or belongs to a +// transaction that was never submitted. +func getTransaction(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + signature, err := decodeHex(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode signature '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetTransaction(runtime, &solana.GetTransactionRequest{ + Signature: signature, + }).Await() + + return outcome{empty: reply == nil || reply.Slot == 0, err: err}, nil +} + +// getSignatureStatusesWithInvalidSignature puts a malformed signature into the batch. +func getSignatureStatusesWithInvalidSignature(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + signature, err := decodeHex(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode signature '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetSignatureStatuses(runtime, &solana.GetSignatureStatusesRequest{ + Sigs: [][]byte{signature}, + }).Await() + + return outcome{empty: reply == nil || len(reply.Results) == 0, err: err}, nil +} + +// getSignatureStatusesOverBatchLimit asks for more statuses than +// PerWorkflow.ChainRead.Solana.BatchItemLimit allows. +func getSignatureStatusesOverBatchLimit(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + count, err := strconv.Atoi(testCase.InvalidInput) + if err != nil { + return outcome{}, fmt.Errorf("failed to decode signature count '%s': %w", testCase.InvalidInput, err) + } + + reply, err := client.GetSignatureStatuses(runtime, &solana.GetSignatureStatusesRequest{ + Sigs: generateSignatures(count), + }).Await() + + return outcome{empty: reply == nil || len(reply.Results) == 0, err: err}, nil +} + +// getFeeForMessage estimates the fee of something that is not an encoded Solana message. +func getFeeForMessage(client solana.Client, runtime cre.Runtime, testCase config.Case) (outcome, error) { + reply, err := client.GetFeeForMessage(runtime, &solana.GetFeeForMessageRequest{ + Message: testCase.InvalidInput, + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + }).Await() + + return outcome{empty: reply == nil || reply.Fee == 0, err: err}, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func defaultAccountInfoOpts() *solana.GetAccountInfoOpts { + return &solana.GetAccountInfoOpts{ + Encoding: solana.EncodingType_ENCODING_TYPE_JSON_PARSED, + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + } +} + +func defaultMultipleAccountsOpts() *solana.GetMultipleAccountsOpts { + return &solana.GetMultipleAccountsOpts{ + Encoding: solana.EncodingType_ENCODING_TYPE_JSON_PARSED, + Commitment: solana.CommitmentType_COMMITMENT_TYPE_CONFIRMED, + } +} + +// validAccount is the system program, a well-formed key that always exists on-chain. It is used +// by the cases whose invalid input is something other than the account itself. +func validAccount() []byte { + return make([]byte, solanaPublicKeyLength) +} + +const ( + solanaPublicKeyLength = 32 + solanaSignatureLength = 64 +) + +// generateAccounts builds distinct, well-formed public keys to fill a batch request with. +func generateAccounts(count int) [][]byte { + accounts := make([][]byte, count) + for i := range accounts { + account := make([]byte, solanaPublicKeyLength) + binary.BigEndian.PutUint64(account, uint64(i)+1) + accounts[i] = account + } + return accounts +} + +// generateSignatures builds distinct, well-formed signatures to fill a batch request with. +func generateSignatures(count int) [][]byte { + signatures := make([][]byte, count) + for i := range signatures { + signature := make([]byte, solanaSignatureLength) + binary.BigEndian.PutUint64(signature, uint64(i)+1) + signatures[i] = signature + } + return signatures +} + +func parseInt32(s string) (int32, error) { + parsed, err := strconv.ParseInt(s, 10, 32) + return int32(parsed), err +} + +// decodeHex decodes a hex-encoded byte string of any length. An empty string decodes to no bytes, +// which reaches the capability as a nil field. +func decodeHex(s string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(s, "0x")) +} diff --git a/system-tests/tests/regression/cre/solana/solwrite-negative/config/config.go b/system-tests/tests/regression/cre/solana/solwrite-negative/config/config.go new file mode 100644 index 00000000000..4cd3ba0d991 --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solwrite-negative/config/config.go @@ -0,0 +1,39 @@ +package config + +import solanago "github.com/gagliardetto/solana-go" + +// Case is a single negative write case. A batch of them runs in one workflow execution, which is +// what keeps the suite from paying the workflow compile/deploy/trigger cost per case. +type Case struct { + Name string + // FunctionToTest must match one of the switch-case labels in solwrite-negative/main.go + FunctionToTest string + // InvalidInput is interpreted based on FunctionToTest: + // - receiver/account keys: hex-encoded raw bytes of any length (empty string means "no bytes") + // - compute limit: decimal string + // Cases that build their invalid input from the config addresses ignore it. + InvalidInput string + // ExpectedError is the substring the capability error must contain. + ExpectedError string +} + +// Config is the configuration of the Solana write negative workflow. +type Config struct { + ChainSelector uint64 + // BatchName identifies the batch in the workflow logs the test asserts on. + BatchName string + // Cases all run in a single execution, so their number must stay within + // PerWorkflow.ChainWrite.TargetsLimit - the engine counts rejected calls too. + Cases []Case + + // ForwarderProgramID is the deployed CRE forwarder program. + ForwarderProgramID solanago.PublicKey + // ForwarderState is the forwarder state account the Solana capability is configured with. + ForwarderState solanago.PublicKey + // Receiver is a deployed, executable program used as a valid receiver, so that cases + // which are not about the receiver reach the validation step they actually target. + Receiver solanago.PublicKey + // NonExecutableAccount is an existing, funded, system-owned account: it exists on-chain + // but is not a program, which is what the "non-executable receiver" case needs. + NonExecutableAccount solanago.PublicKey +} diff --git a/system-tests/tests/regression/cre/solana/solwrite-negative/go.mod b/system-tests/tests/regression/cre/solana/solwrite-negative/go.mod new file mode 100644 index 00000000000..90721fdf654 --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solwrite-negative/go.mod @@ -0,0 +1,48 @@ +module github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solwrite-negative + +go 1.26.5 + +require ( + github.com/gagliardetto/solana-go v1.14.0 + github.com/smartcontractkit/cre-sdk-go v1.5.0 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260512121445-c641f2952d28 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.1 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/gagliardetto/binary v0.8.0 // indirect + github.com/gagliardetto/treeout v0.1.4 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f // indirect + github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.mongodb.org/mongo-driver v1.17.7 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + +replace github.com/mattn/go-isatty => github.com/Unheilbar/go-isatty v0.0.2 // original isatty doesn't support wasim diff --git a/system-tests/tests/regression/cre/solana/solwrite-negative/go.sum b/system-tests/tests/regression/cre/solana/solwrite-negative/go.sum new file mode 100644 index 00000000000..23ebbf4b98f --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solwrite-negative/go.sum @@ -0,0 +1,146 @@ +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Unheilbar/go-isatty v0.0.2 h1:bTDPH7NsGwWHyfy57ZUT5Q+V27yx7+/F5QLipGZrHvg= +github.com/Unheilbar/go-isatty v0.0.2/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw= +github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f h1:t+OoYaXLdH0WHK2pbWKjTSnSQa5JBQD1+gf0yISYfQk= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/cre-sdk-go v1.5.0 h1:kepW3QDKARrOOHjXwWAZ9j5KLk6bxLzvi6OMrLsFwVo= +github.com/smartcontractkit/cre-sdk-go v1.5.0/go.mod h1:yYrQFz1UH7hhRbPO0q4fgo1tfsJNd4yXnI3oCZE0RzM= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260512121445-c641f2952d28 h1:VQDtovithZncpCPUx4JUJUmpvMhG7xEeDevnSjNjQvo= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260512121445-c641f2952d28/go.mod h1:GVv+jg5WjVtmQxsZp0O+u3mqfvpR8DL1tdtkUnZUQcc= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 h1:qBZ4y6qlTOynSpU1QAi2Fgr3tUZQ332b6hit9EVZqkk= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0/go.mod h1:Rzhy75vD3FqQo/SV6lypnxIwjWac6IOWzI5BYj3tYMU= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= +go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/system-tests/tests/regression/cre/solana/solwrite-negative/main.go b/system-tests/tests/regression/cre/solana/solwrite-negative/main.go new file mode 100644 index 00000000000..d86a80e5acc --- /dev/null +++ b/system-tests/tests/regression/cre/solana/solwrite-negative/main.go @@ -0,0 +1,307 @@ +//go:build wasip1 + +package main + +import ( + "encoding/hex" + "fmt" + "log/slog" + "strconv" + "strings" + + solanago "github.com/gagliardetto/solana-go" + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana" + solanabindings "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana/bindings" + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + + "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solwrite-negative/config" +) + +// Function names, they must literally match the `functionToTest` values used by the +// regression test (system-tests/tests/regression/cre/solana_regression_test.go). +const ( + writeReportInvalidReceiverLength = "WriteReport - invalid receiver length" + writeReportZeroReceiver = "WriteReport - zero receiver" + writeReportNonExistentReceiver = "WriteReport - non-existent receiver" + writeReportNonExecutableReceiver = "WriteReport - non-executable receiver" + writeReportTooFewRemainingAccounts = "WriteReport - too few remaining accounts" + writeReportInvalidRemainingAccount = "WriteReport - invalid remaining account key" + writeReportForwarderStateMismatch = "WriteReport - mismatched forwarder state" + writeReportAccountHashMismatch = "WriteReport - remaining accounts hash mismatch" + writeReportComputeLimitExceeded = "WriteReport - compute limit exceeded" +) + +// defaultComputeLimit stays below PerWorkflow.ChainWrite.Solana.GasLimit (300_000), so that +// cases which are not about the compute limit are never rejected by the compute limiter first. +const defaultComputeLimit = uint32(290_000) + +// maxWriteCallsPerExecution mirrors PerWorkflow.ChainWrite.TargetsLimit. The engine counts every +// attempted WriteReport, including the ones the capability rejects, so a batch that goes over the +// limit would start failing with a call-limit error instead of the error each case is about. +const maxWriteCallsPerExecution = 10 + +// reportPayload is an arbitrary, small payload: every case here is expected to fail during +// request validation, so the payload is never CPI-ed into the receiver program. +var reportPayload = []byte{1, 2, 3} + +func main() { + wasm.NewRunner(parseConfig).Run(RunSolanaWriteNegativeWorkflow) +} + +func parseConfig(b []byte) (config.Config, error) { + var wfCfg config.Config + if err := yaml.Unmarshal(b, &wfCfg); err != nil { + return config.Config{}, fmt.Errorf("error unmarshalling config: %w", err) + } + return wfCfg, nil +} + +func RunSolanaWriteNegativeWorkflow(wfCfg config.Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[config.Config], error) { + return cre.Workflow[config.Config]{ + cre.Handler( + cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}), + onSolanaWriteTrigger, + ), + }, nil +} + +// writeAttempt describes a single WriteReport call: what is sent to the capability and what the +// report commits to. hashedAccounts is what the report's account hash is computed over; it is +// identical to remainingAccounts for every case except the account-hash-mismatch one. +type writeAttempt struct { + receiver []byte + remainingAccounts []*solana.AccountMeta + hashedAccounts []*solana.AccountMeta + computeLimit uint32 +} + +// onSolanaWriteTrigger runs every case of the batch in one execution and verifies each of them +// in place, so that the test only has to wait for a single "batch passed" log. +func onSolanaWriteTrigger(wfCfg config.Config, runtime cre.Runtime, payload *cron.Payload) (any, error) { + runtime.Logger().Info("onSolanaWriteTrigger called", "batch", wfCfg.BatchName, "cases", len(wfCfg.Cases), "payload", payload) + + if len(wfCfg.Cases) == 0 { + return nil, fmt.Errorf("batch '%s' has no cases to run", wfCfg.BatchName) + } + if len(wfCfg.Cases) > maxWriteCallsPerExecution { + return nil, fmt.Errorf("batch '%s' has %d cases, which is over the %d writes a single execution is allowed", + wfCfg.BatchName, len(wfCfg.Cases), maxWriteCallsPerExecution) + } + + client := solana.Client{ChainSelector: wfCfg.ChainSelector} + runtime.Logger().Info("Solana client created", "chainSelector", wfCfg.ChainSelector) + + for _, testCase := range wfCfg.Cases { + callErr, err := runCase(client, runtime, wfCfg, testCase) + if err != nil { + runtime.Logger().Error(fmt.Sprintf("Solana write negative batch '%s' could not run case '%s' (%s): %v", + wfCfg.BatchName, testCase.Name, testCase.FunctionToTest, err)) + return nil, fmt.Errorf("could not run case '%s' (%s): %w", testCase.Name, testCase.FunctionToTest, err) + } + + if err := verify(testCase, callErr); err != nil { + runtime.Logger().Error(fmt.Sprintf("Solana write negative batch '%s' failed on case '%s' (%s): %v", + wfCfg.BatchName, testCase.Name, testCase.FunctionToTest, err)) + return nil, fmt.Errorf("case '%s' (%s) failed: %w", testCase.Name, testCase.FunctionToTest, err) + } + + runtime.Logger().Info(fmt.Sprintf("case '%s' (%s) passed: got expected error: %v", testCase.Name, testCase.FunctionToTest, callErr)) + } + + // The test waits for this line, and for this line only. + runtime.Logger().Info(fmt.Sprintf("Solana write negative batch '%s' passed (%d cases)", wfCfg.BatchName, len(wfCfg.Cases))) + return fmt.Sprintf("batch '%s' passed", wfCfg.BatchName), nil +} + +// runCase performs the WriteReport call of a single case. The returned error means the case could +// not be run at all (for example its input could not be decoded); the capability's own error is +// returned as callErr, because for these cases it is the expected result. +func runCase(client solana.Client, runtime cre.Runtime, wfCfg config.Config, testCase config.Case) (callErr error, err error) { + runtime.Logger().Info("running case", "name", testCase.Name, "functionToTest", testCase.FunctionToTest, "invalidInput", testCase.InvalidInput) + + attempt, err := buildAttempt(wfCfg, testCase) + if err != nil { + return nil, err + } + + return writeReport(runtime, client, attempt) +} + +// verify checks what the capability answered against what the case expects. The error it returns +// describes the mismatch, so a failing batch names the case and what it actually got. +func verify(testCase config.Case, callErr error) error { + if callErr == nil { + return fmt.Errorf("expected an error containing %q, got a successful write", testCase.ExpectedError) + } + if !strings.Contains(callErr.Error(), testCase.ExpectedError) { + return fmt.Errorf("expected an error containing %q, got: %v", testCase.ExpectedError, callErr) + } + return nil +} + +// buildAttempt returns the (intentionally invalid) WriteReport inputs for the given case. +func buildAttempt(wfCfg config.Config, testCase config.Case) (writeAttempt, error) { + remainingAccounts, err := validRemainingAccounts(wfCfg) + if err != nil { + return writeAttempt{}, err + } + + attempt := writeAttempt{ + receiver: wfCfg.Receiver.Bytes(), + remainingAccounts: remainingAccounts, + computeLimit: defaultComputeLimit, + } + + switch testCase.FunctionToTest { + case writeReportInvalidReceiverLength, writeReportNonExistentReceiver: + // The test supplies the raw receiver bytes: a key that is not 32 bytes long, or a + // well-formed key of an account that was never created on-chain. + receiver, decodeErr := decodeHex(testCase.InvalidInput) + if decodeErr != nil { + return writeAttempt{}, fmt.Errorf("failed to decode receiver '%s': %w", testCase.InvalidInput, decodeErr) + } + attempt.receiver = receiver + + case writeReportZeroReceiver: + attempt.receiver = make([]byte, solanago.PublicKeyLength) + + case writeReportNonExecutableReceiver: + attempt.receiver = wfCfg.NonExecutableAccount.Bytes() + + case writeReportTooFewRemainingAccounts: + // The forwarder layout requires at least the state and the authority accounts. + attempt.remainingAccounts = remainingAccounts[:1] + + case writeReportInvalidRemainingAccount: + invalidKey, decodeErr := decodeHex(testCase.InvalidInput) + if decodeErr != nil { + return writeAttempt{}, fmt.Errorf("failed to decode remaining account key '%s': %w", testCase.InvalidInput, decodeErr) + } + attempt.remainingAccounts = append(attempt.remainingAccounts, &solana.AccountMeta{PublicKey: invalidKey}) + + case writeReportForwarderStateMismatch: + // Index 0 must be the forwarder state the capability is configured with. + tampered := cloneAccounts(remainingAccounts) + tampered[0] = &solana.AccountMeta{PublicKey: wfCfg.Receiver.Bytes()} + attempt.remainingAccounts = tampered + + case writeReportAccountHashMismatch: + // The report commits to the valid account list, but a different list is submitted. + attempt.hashedAccounts = remainingAccounts + attempt.remainingAccounts = append(cloneAccounts(remainingAccounts), &solana.AccountMeta{PublicKey: wfCfg.ForwarderProgramID.Bytes()}) + + case writeReportComputeLimitExceeded: + // Everything else stays valid so that validation passes and the compute limiter is reached. + computeLimit, parseErr := strconv.ParseUint(testCase.InvalidInput, 10, 32) + if parseErr != nil { + return writeAttempt{}, fmt.Errorf("failed to parse compute limit '%s': %w", testCase.InvalidInput, parseErr) + } + attempt.computeLimit = uint32(computeLimit) //nolint:gosec // ParseUint above bounds the value to 32 bits + + default: + return writeAttempt{}, fmt.Errorf("the provided name for function to test in regression Solana Write Workflow did not match any known functions: %s", testCase.FunctionToTest) + } + + if attempt.hashedAccounts == nil { + attempt.hashedAccounts = attempt.remainingAccounts + } + + return attempt, nil +} + +// validRemainingAccounts builds the account list in the layout the keystone forwarder expects: +// index 0 is the forwarder state, index 1 the forwarder authority PDA, index 2+ receiver accounts. +func validRemainingAccounts(wfCfg config.Config) ([]*solana.AccountMeta, error) { + authority, err := deriveForwarderAuthority(wfCfg.ForwarderState, wfCfg.Receiver, wfCfg.ForwarderProgramID) + if err != nil { + return nil, fmt.Errorf("failed to derive forwarder authority: %w", err) + } + + return []*solana.AccountMeta{ + {PublicKey: wfCfg.ForwarderState.Bytes()}, + {PublicKey: authority.Bytes()}, + {PublicKey: wfCfg.Receiver.Bytes(), IsWritable: true}, + }, nil +} + +func deriveForwarderAuthority(forwarderState, receiverProgram, forwarderProgram solanago.PublicKey) (solanago.PublicKey, error) { + seeds := [][]byte{ + []byte("forwarder"), + forwarderState[:], + receiverProgram[:], + } + authority, _, err := solanago.FindProgramAddress(seeds, forwarderProgram) + return authority, err +} + +// writeReport submits the report and returns the capability error, which is the expected result +// for every case here. The second error means the report could not be generated at all. +func writeReport(runtime cre.Runtime, client solana.Client, attempt writeAttempt) (callErr error, err error) { + report, err := generateForwarderReport(runtime, attempt.hashedAccounts) + if err != nil { + return nil, fmt.Errorf("failed to generate report: %w", err) + } + + runtime.Logger().Info("Writing report...", + "receiver", hex.EncodeToString(attempt.receiver), + "remainingAccounts", formatAccounts(attempt.remainingAccounts), + "computeLimit", attempt.computeLimit, + ) + + reply, callErr := client.WriteReport(runtime, &solana.WriteCreReportRequest{ + Receiver: attempt.receiver, + RemainingAccounts: attempt.remainingAccounts, + ComputeConfig: &solana.ComputeConfig{ComputeLimit: attempt.computeLimit}, + Report: report, + }).Await() + if callErr == nil { + runtime.Logger().Info("WriteReport unexpectedly succeeded", "write_output", reply) + } + + return callErr, nil +} + +// generateForwarderReport produces a report whose payload is the Borsh-encoded forwarder report, +// committing to the hash of the given accounts - the same encoding the generated Solana bindings use. +func generateForwarderReport(runtime cre.Runtime, hashedAccounts []*solana.AccountMeta) (*cre.Report, error) { + forwarderReport := solanabindings.ForwarderReport{ + AccountHash: solanabindings.CalculateAccountsHash(hashedAccounts), + Payload: reportPayload, + } + + encoded, err := forwarderReport.Marshal() + if err != nil { + return nil, fmt.Errorf("failed to encode forwarder report: %w", err) + } + + return runtime.GenerateReport(&cre.ReportRequest{ + EncodedPayload: encoded, + EncoderName: "solana", + SigningAlgo: "ecdsa", + HashingAlgo: "keccak256", + }).Await() +} + +func cloneAccounts(accounts []*solana.AccountMeta) []*solana.AccountMeta { + cloned := make([]*solana.AccountMeta, len(accounts)) + copy(cloned, accounts) + return cloned +} + +func formatAccounts(accounts []*solana.AccountMeta) string { + formatted := make([]string, len(accounts)) + for i, account := range accounts { + formatted[i] = hex.EncodeToString(account.GetPublicKey()) + } + return strings.Join(formatted, ",") +} + +// decodeHex decodes a hex-encoded byte string of any length. An empty string decodes to no bytes, +// which is how the "empty receiver" style cases are expressed. +func decodeHex(s string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(s, "0x")) +} diff --git a/system-tests/tests/regression/cre/solana_regression_test.go b/system-tests/tests/regression/cre/solana_regression_test.go new file mode 100644 index 00000000000..74b37d87171 --- /dev/null +++ b/system-tests/tests/regression/cre/solana_regression_test.go @@ -0,0 +1,701 @@ +package cre + +import ( + "context" + "fmt" + "math/big" + "math/rand" + "strings" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + commonevents "github.com/smartcontractkit/chainlink-protos/workflows/go/common" + workflowevents "github.com/smartcontractkit/chainlink-protos/workflows/go/events" + "github.com/smartcontractkit/chainlink-testing-framework/framework" + ks_sol "github.com/smartcontractkit/chainlink/deployment/cre/forwarder/solana" + crelib "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/solana" + sollogtrigger_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative/config" + solread_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solread-negative/config" + solwrite_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solwrite-negative/config" + t_helpers "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers" + ttypes "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers/configuration" +) + +// Solana regression tests need a topology that runs a Solana chain and the Solana capability. +const solanaRegressionConfigPath = "/configs/workflow-don-solana.toml" + +const ( + solanaWriteNegativeWorkflowFile = "./solana/solwrite-negative/main.go" + solanaReadNegativeWorkflowFile = "./solana/solread-negative/main.go" + solanaLogTriggerNegativeWorkflowFile = "./solana/sollogtrigger-negative/main.go" +) + +// Read and write cases run in batches: one workflow deployment covers a whole batch, instead of +// paying the compile/deploy/trigger cost per case. A batch runs in a single workflow execution, +// so it is bounded by the engine's per-execution call limits - and the engine counts the calls +// the capability rejects too, which is every call these tests make. +const ( + // solanaMaxReadCallsPerBatch mirrors PerWorkflow.ChainRead.CallLimit. + solanaMaxReadCallsPerBatch = 15 + // solanaMaxWriteCallsPerBatch mirrors PerWorkflow.ChainWrite.TargetsLimit. + solanaMaxWriteCallsPerBatch = 10 +) + +// solanaTestNameTemplate is a template for Solana negative test names to avoid duplication, +// e.g. "Solana. fails with ". +const solanaTestNameTemplate = "Solana.%s fails with %s" + +// solanaNegativeTest is one negative case. For read cases an empty expectedError means the +// request is well-formed, so the capability may answer with either an error or an empty +// response - returning real data is the only failure. +// +// The tables below hold one case per validation branch, not one per malformed input: the +// capability checks a key's length, not its shape, so an empty, one-byte, short and long key all +// take the same branch and prove the same thing. Inputs are only repeated across methods where +// the code path genuinely differs - a singular field (GetBalance.Addr) and a repeated one +// (GetMultipleAccounts.Accounts) are validated by different converters, so both are covered. +type solanaNegativeTest struct { + name string + invalidInput string + functionToTest string + expectedError string +} + +// nonExistentSolanaAccount is a well-formed 32-byte key that no test ever creates or funds, +// so the account behind it never exists on the local validator. +const nonExistentSolanaAccount = "beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef" + +// Malformed keys and signatures: they are passed to the capability as raw bytes, so unlike EVM +// addresses they are never padded or truncated into something valid. +var ( + solanaShortKey = strings.Repeat("ab", 31) + solanaShortSignature = strings.Repeat("ab", 63) + solanaUnknownSignature = strings.Repeat("ab", 64) +) + +// solanaFundingAmount is 1 SOL, enough to make the funded address exist on-chain as a +// system-owned (and therefore non-executable) account. +var solanaFundingAmount = big.NewInt(1_000_000_000) + +// Test_CRE_V2_Solana_Regression runs all Solana regression scenarios as subtests under a single +// CI matrix entry, mirroring Test_CRE_V2_Stellar_Regression. +// +// Unlike the Stellar group, which boots one test environment and shares it across its subtests, +// every Solana subtest sets up its own environment with per-test keys, so the whole group can +// run in parallel (CRE_TEST_PARALLEL_ENABLED) without the subtests colliding on the shared +// registry deployer nonces. +func Test_CRE_V2_Solana_Regression(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + + t.Run("WriteReportInvalidReceiver", func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + runSolanaWriteNegativeTestSuite(t, "invalid-receiver", solanaNegativeTestsWriteReportInvalidReceiver) + }) + + t.Run("WriteReportInvalidPayload", func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + runSolanaWriteNegativeTestSuite(t, "invalid-payload", solanaNegativeTestsWriteReportInvalidPayload) + }) + + t.Run("ReadAccountCalls", func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + runSolanaReadNegativeTestSuite(t, "account-calls", solanaReadBatchAccountCalls) + }) + + t.Run("ReadProgramBlockAndTxCalls", func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + runSolanaReadNegativeTestSuite(t, "program-block-and-tx-calls", solanaReadBatchProgramBlockAndTxCalls) + }) + + // Log trigger cases cannot be batched - a rejected registration fails the whole engine, so + // each case runs as its own subtest with its own workflow deployment. + for _, tCase := range solanaNegativeTestsLogTrigger { + testName := fmt.Sprintf(solanaTestNameTemplate, tCase.functionToTest, tCase.name) + t.Run(testName, func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + testEnv := t_helpers.SetupTestEnvironmentWithPerTestKeys(t, t_helpers.GetTestConfig(t, solanaRegressionConfigPath)) + + SolanaLogTriggerFailsTest(t, testEnv, tCase) + }) + } +} + +////////////////////////////////////////////////////// +// WRITE NEGATIVE TESTS +////////////////////////////////////////////////////// + +// ...Function variables must literally match the names of the switch-case statements in the +// workflow (solana/solwrite-negative/main.go). In each case the Solana capability's WriteReport +// is called with one invalid input, and the capability is expected to reject the request during +// validation, before anything is submitted on-chain. +const ( + solanaWriteReportInvalidReceiverLength = "WriteReport - invalid receiver length" + solanaWriteReportZeroReceiver = "WriteReport - zero receiver" + solanaWriteReportNonExistentReceiver = "WriteReport - non-existent receiver" + solanaWriteReportNonExecutableReceiver = "WriteReport - non-executable receiver" + solanaWriteReportTooFewRemainingAccounts = "WriteReport - too few remaining accounts" + solanaWriteReportInvalidRemainingAccount = "WriteReport - invalid remaining account key" + solanaWriteReportForwarderStateMismatch = "WriteReport - mismatched forwarder state" + solanaWriteReportAccountHashMismatch = "WriteReport - remaining accounts hash mismatch" + solanaWriteReportComputeLimitExceeded = "WriteReport - compute limit exceeded" +) + +const ( + expectedSolanaReceiverNotPublicKey = "received public key is not 32 bytes long" + expectedSolanaReceiverEmpty = "receiver public key is empty" + expectedSolanaReceiverMissing = "receiver account does not exist" + expectedSolanaReceiverNonExecutable = "receiver account is non-executable" + expectedSolanaTooFewRemainingAccounts = "expected accounts meta length > 2" + expectedSolanaInvalidRemainingAccount = "public key must be exactly 32 bytes" + expectedSolanaForwarderStateMismatch = "doesn't match configured forwarder state" + expectedSolanaAccountHashMismatch = "remaining account hash mismatch" + expectedSolanaComputeLimitExceeded = "provided compute config exceeds limit" +) + +var solanaNegativeTestsWriteReportInvalidReceiver = []solanaNegativeTest{ + // WriteReport - receiver the forwarder can never deliver to. The capability checks the + // receiver's length, so one malformed length stands in for empty/short/long keys. + {"31 bytes (short) key", solanaShortKey, solanaWriteReportInvalidReceiverLength, expectedSolanaReceiverNotPublicKey}, + {"zero key", "", solanaWriteReportZeroReceiver, expectedSolanaReceiverEmpty}, + {"non-existent account", nonExistentSolanaAccount, solanaWriteReportNonExistentReceiver, expectedSolanaReceiverMissing}, + // A funded system account exists on-chain but is not a program, so it can never receive a report. + {"funded system account", "", solanaWriteReportNonExecutableReceiver, expectedSolanaReceiverNonExecutable}, +} + +var solanaNegativeTestsWriteReportInvalidPayload = []solanaNegativeTest{ + // WriteReport - remaining accounts that do not match the forwarder's expected layout. + // Index 0 must be the configured forwarder state and index 1 the forwarder authority PDA. + {"only forwarder state", "", solanaWriteReportTooFewRemainingAccounts, expectedSolanaTooFewRemainingAccounts}, + {"31 bytes (short) key", solanaShortKey, solanaWriteReportInvalidRemainingAccount, expectedSolanaInvalidRemainingAccount}, + {"foreign forwarder state", "", solanaWriteReportForwarderStateMismatch, expectedSolanaForwarderStateMismatch}, + // The report commits to a hash of the accounts, so submitting a different account list must be rejected. + {"accounts not matching the report hash", "", solanaWriteReportAccountHashMismatch, expectedSolanaAccountHashMismatch}, + // WriteReport - compute limit above PerWorkflow.ChainWrite.Solana.GasLimit (300_000 CUs by + // default). Any value over the limit takes the same branch, so one is enough. + {"above the configured limit", "300001", solanaWriteReportComputeLimitExceeded, expectedSolanaComputeLimitExceeded}, +} + +// SolanaWriteFailsTest deploys the Solana write negative workflow once for a whole batch of cases. +// The workflow runs every case in a single execution and checks each of them against its expected +// error, so the test waits for one "batch passed" log. A failing case fails the batch with a log +// that names the case and what it actually got back. +func SolanaWriteFailsTest(t *testing.T, testEnv *ttypes.TestEnvironment, batchName string, tests []solanaNegativeTest) { + testLogger := framework.L + + cases := toSolanaWriteCases(tests) + + solChain := mustSolanaChainInEnv(t, testEnv) + chainSelector := solChain.ChainSelector() + dataStore := testEnv.CreEnvironment.CldfEnvironment.DataStore + + forwarderProgramID := mustGetSolanaContract(t, dataStore, chainSelector, ks_sol.ForwarderContract) + forwarderState := mustGetSolanaContract(t, dataStore, chainSelector, ks_sol.ForwarderState) + + userLogsCh, baseMessageCh := startSolanaChipSink(t, testLogger) + + testLogger.Info().Msgf("Creating Solana Write Fail workflow configuration for batch '%s' (%d cases)...", batchName, len(cases)) + workflowConfig := solwrite_negative_config.Config{ + ChainSelector: chainSelector, + BatchName: batchName, + Cases: cases, + // The forwarder program is a deployed, executable program, so it is a valid receiver for + // the cases that are not about the receiver itself. + Receiver: forwarderProgramID, + ForwarderProgramID: forwarderProgramID, + ForwarderState: forwarderState, + } + + if batchNeedsNonExecutableAccount(cases) { + workflowConfig.NonExecutableAccount = mustCreateFundedSolanaAccount(t, testLogger, solChain) + } + + workflowName := fmt.Sprintf("sol-write-fail-workflow-%d-%04d", chainSelector, rand.Intn(10000)) + workflowID := t_helpers.CompileAndDeployWorkflow( + t, + testEnv, + testLogger, + workflowName, + &workflowConfig, + solanaWriteNegativeWorkflowFile, + ) + + t_helpers.WatchWorkflowLogs( + t, + testLogger, + userLogsCh, + baseMessageCh, + t_helpers.WorkflowEngineInitErrorLog, + solanaWriteBatchPassedLog(batchName, len(cases)), + 2*time.Minute, + t_helpers.WithUserLogWorkflowID(workflowID), + ) + testLogger.Info().Msgf("Solana Write Fail test successfully completed for batch '%s'", batchName) +} + +// runSolanaWriteNegativeTestSuite runs one batch of Solana write negative cases. +func runSolanaWriteNegativeTestSuite(t *testing.T, batchName string, tests []solanaNegativeTest) { + requireSolanaBatchFitsCallLimit(t, batchName, len(tests), solanaMaxWriteCallsPerBatch, "writes") + + testEnv := t_helpers.SetupTestEnvironmentWithPerTestKeys(t, t_helpers.GetTestConfig(t, solanaRegressionConfigPath)) + + SolanaWriteFailsTest(t, testEnv, batchName, tests) +} + +////////////////////////////////////////////////////// +// READ NEGATIVE TESTS +////////////////////////////////////////////////////// + +// ...Function variables must literally match the names of the switch-case statements in the +// workflow (solana/solread-negative/main.go). +const ( + solanaGetAccountInfoInvalidAccount = "GetAccountInfoWithOpts - invalid account" + solanaGetAccountInfoNonExistentAccount = "GetAccountInfoWithOpts - non-existent account" + solanaGetAccountInfoInvalidCommitment = "GetAccountInfoWithOpts - invalid commitment" + solanaGetAccountInfoInvalidEncoding = "GetAccountInfoWithOpts - invalid encoding" + solanaGetBalanceInvalidAddress = "GetBalance - invalid address" + solanaGetBalanceNonExistentAccount = "GetBalance - non-existent account" + solanaGetMultipleAccountsInvalidAccount = "GetMultipleAccountsWithOpts - invalid account" + solanaGetMultipleAccountsTooManyItems = "GetMultipleAccountsWithOpts - too many accounts" + solanaGetProgramAccountsInvalidProgram = "GetProgramAccounts - invalid program" + solanaGetProgramAccountsNonProgram = "GetProgramAccounts - non-program account" + solanaGetBlockInvalidSlot = "GetBlock - invalid slot" + solanaGetSlotHeightInvalidCommitment = "GetSlotHeight - invalid commitment" + solanaGetTransactionInvalidSignature = "GetTransaction - invalid signature" + solanaGetTransactionUnknownSignature = "GetTransaction - unknown signature" + solanaGetSignatureStatusesInvalidSig = "GetSignatureStatuses - invalid signature" + solanaGetSignatureStatusesTooManyItems = "GetSignatureStatuses - too many signatures" + solanaGetFeeForMessageInvalidMessage = "GetFeeForMessage - invalid message" +) + +const ( + expectedSolanaNilAddress = "address can't be nil" + expectedSolanaInvalidPublicKey = "invalid public key: got %d bytes, expected 32" + expectedSolanaNilSignature = "signature can't be nil" + expectedSolanaInvalidSignature = "invalid signature: got %d bytes, expected 64" + expectedSolanaUnknownCommitment = "unknown commitment type" + expectedSolanaUnknownEncoding = "unknown encoding type" + // the batch limiter names the setting it enforces, the same way the EVM log query limiter does + expectedSolanaBatchItemLimit = "BatchItemLimit" + // acceptErrorOrEmpty marks cases whose request is well-formed, so the capability is free to + // answer with either an error or an empty response. + acceptErrorOrEmpty = "" +) + +// solanaBatchItemLimitExceeded is one more than PerWorkflow.ChainRead.Solana.BatchItemLimit (100). +const solanaBatchItemLimitExceeded = "101" + +// invalidSolanaEnumValue is outside the range of every commitment/encoding enum. +const invalidSolanaEnumValue = "99" + +var solanaNegativeTestsGetAccountInfo = []solanaNegativeTest{ + // GetAccountInfoWithOpts - an unset account reaches the capability as a nil field, a + // malformed one fails the length check: two branches, two cases. + {"unset account", "", solanaGetAccountInfoInvalidAccount, expectedSolanaNilAddress}, + {"31 bytes (short) key", solanaShortKey, solanaGetAccountInfoInvalidAccount, invalidPublicKeyError(31)}, + // A well-formed key of an account that was never created is a valid request, so the answer is + // expected to be an error or an empty account rather than account data. + {"non-existent account", nonExistentSolanaAccount, solanaGetAccountInfoNonExistentAccount, acceptErrorOrEmpty}, + // This is the only read that carries both opts enums, so it covers them for all of them. + {"unknown commitment", invalidSolanaEnumValue, solanaGetAccountInfoInvalidCommitment, expectedSolanaUnknownCommitment}, + {"unknown encoding", invalidSolanaEnumValue, solanaGetAccountInfoInvalidEncoding, expectedSolanaUnknownEncoding}, +} + +var solanaNegativeTestsGetBalance = []solanaNegativeTest{ + // GetBalance - its own converter, so the address branches are worth covering again here + {"unset address", "", solanaGetBalanceInvalidAddress, expectedSolanaNilAddress}, + {"31 bytes (short) key", solanaShortKey, solanaGetBalanceInvalidAddress, invalidPublicKeyError(31)}, + // Solana reports zero lamports for an account that was never funded. + {"non-existent account", nonExistentSolanaAccount, solanaGetBalanceNonExistentAccount, acceptErrorOrEmpty}, +} + +var solanaNegativeTestsGetMultipleAccounts = []solanaNegativeTest{ + // GetMultipleAccountsWithOpts - a repeated field, validated per element, so one malformed key + // is enough to reject the whole batch + {"31 bytes (short) key in the batch", solanaShortKey, solanaGetMultipleAccountsInvalidAccount, invalidPublicKeyError(31)}, + // Every key of this batch is valid, so it can only be rejected by the batch item limiter. + {"over the batch item limit", solanaBatchItemLimitExceeded, solanaGetMultipleAccountsTooManyItems, expectedSolanaBatchItemLimit}, +} + +var solanaNegativeTestsGetProgramAccounts = []solanaNegativeTest{ + // GetProgramAccounts - program that is not a usable public key + {"unset program", "", solanaGetProgramAccountsInvalidProgram, expectedSolanaNilAddress}, + {"31 bytes (short) key", solanaShortKey, solanaGetProgramAccountsInvalidProgram, invalidPublicKeyError(31)}, + // Nothing owns accounts under a key that is not a program. + {"account that owns nothing", nonExistentSolanaAccount, solanaGetProgramAccountsNonProgram, acceptErrorOrEmpty}, +} + +var solanaNegativeTestsGetBlockAndSlotHeight = []solanaNegativeTest{ + // GetBlock - a slot the local validator has never produced + {"slot never produced", "18446744073709551615", solanaGetBlockInvalidSlot, acceptErrorOrEmpty}, + // GetSlotHeight takes nothing but a commitment, so that is all there is to get wrong. + {"unknown commitment", invalidSolanaEnumValue, solanaGetSlotHeightInvalidCommitment, expectedSolanaUnknownCommitment}, +} + +var solanaNegativeTestsGetTransaction = []solanaNegativeTest{ + // GetTransaction - signature that is not a usable 64-byte signature + {"unset signature", "", solanaGetTransactionInvalidSignature, expectedSolanaNilSignature}, + {"63 bytes (short) signature", solanaShortSignature, solanaGetTransactionInvalidSignature, invalidSignatureError(63)}, + // A well-formed signature of a transaction that was never submitted. + {"unknown signature", solanaUnknownSignature, solanaGetTransactionUnknownSignature, acceptErrorOrEmpty}, +} + +var solanaNegativeTestsGetSignatureStatuses = []solanaNegativeTest{ + // GetSignatureStatuses - the repeated counterpart of GetTransaction's signature check + {"63 bytes (short) signature in the batch", solanaShortSignature, solanaGetSignatureStatusesInvalidSig, invalidSignatureError(63)}, + {"over the batch item limit", solanaBatchItemLimitExceeded, solanaGetSignatureStatusesTooManyItems, expectedSolanaBatchItemLimit}, +} + +var solanaNegativeTestsGetFeeForMessage = []solanaNegativeTest{ + // GetFeeForMessage - anything that is not an encoded Solana message. The capability passes + // the string through, so every rejection comes from the RPC and takes the same path. + {"unset message", "", solanaGetFeeForMessageInvalidMessage, acceptErrorOrEmpty}, + {"not an encoded message", "not-a-base64-message", solanaGetFeeForMessageInvalidMessage, acceptErrorOrEmpty}, +} + +// Read cases are grouped into batches that each run in a single workflow execution, and every +// batch must stay within solanaMaxReadCallsPerBatch - when adding cases, either keep a batch +// under it or split the batch. +var ( + // 10 calls + solanaReadBatchAccountCalls = concatSolanaTests( + solanaNegativeTestsGetAccountInfo, + solanaNegativeTestsGetBalance, + solanaNegativeTestsGetMultipleAccounts, + ) + // 12 calls + solanaReadBatchProgramBlockAndTxCalls = concatSolanaTests( + solanaNegativeTestsGetProgramAccounts, + solanaNegativeTestsGetBlockAndSlotHeight, + solanaNegativeTestsGetTransaction, + solanaNegativeTestsGetSignatureStatuses, + solanaNegativeTestsGetFeeForMessage, + ) +) + +// SolanaReadFailsTest deploys the Solana read negative workflow once for a whole batch of cases. +// The workflow runs every case in a single execution and checks each of them against its expected +// outcome, so the test waits for one "batch passed" log. A failing case fails the batch with a log +// that names the case and what it actually got back. +func SolanaReadFailsTest(t *testing.T, testEnv *ttypes.TestEnvironment, batchName string, tests []solanaNegativeTest) { + testLogger := framework.L + + cases := toSolanaReadCases(tests) + + solChain := mustSolanaChainInEnv(t, testEnv) + chainSelector := solChain.ChainSelector() + + userLogsCh, baseMessageCh := startSolanaChipSink(t, testLogger) + + testLogger.Info().Msgf("Creating Solana Read Fail workflow configuration for batch '%s' (%d cases)...", batchName, len(cases)) + workflowConfig := solread_negative_config.Config{ + ChainSelector: chainSelector, + BatchName: batchName, + Cases: cases, + } + + workflowName := fmt.Sprintf("sol-read-fail-workflow-%d-%04d", chainSelector, rand.Intn(10000)) + workflowID := t_helpers.CompileAndDeployWorkflow( + t, + testEnv, + testLogger, + workflowName, + &workflowConfig, + solanaReadNegativeWorkflowFile, + ) + + t_helpers.WatchWorkflowLogs( + t, + testLogger, + userLogsCh, + baseMessageCh, + t_helpers.WorkflowEngineInitErrorLog, + solanaReadBatchPassedLog(batchName, len(cases)), + 2*time.Minute, + t_helpers.WithUserLogWorkflowID(workflowID), + ) + testLogger.Info().Msgf("Solana Read Fail test successfully completed for batch '%s'", batchName) +} + +// runSolanaReadNegativeTestSuite runs one batch of Solana read negative cases. +func runSolanaReadNegativeTestSuite(t *testing.T, batchName string, tests []solanaNegativeTest) { + requireSolanaBatchFitsCallLimit(t, batchName, len(tests), solanaMaxReadCallsPerBatch, "chain reads") + + testEnv := t_helpers.SetupTestEnvironmentWithPerTestKeys(t, t_helpers.GetTestConfig(t, solanaRegressionConfigPath)) + + SolanaReadFailsTest(t, testEnv, batchName, tests) +} + +////////////////////////////////////////////////////// +// LOG TRIGGER NEGATIVE TESTS +////////////////////////////////////////////////////// + +// ...Function variables must literally match the names of the switch-case statements in the +// workflow (solana/sollogtrigger-negative/main.go). Every case here is rejected while the +// workflow engine registers the trigger, so the failure shows up as an engine init error. +// +// Unlike reads and writes these cases cannot be batched: a rejected registration fails the whole +// engine, so a workflow can only ever demonstrate one rejected filter. +const ( + solanaLogTriggerInvalidAddress = "LogTrigger - invalid address" + solanaLogTriggerEmptyEventName = "LogTrigger - empty event name" + solanaLogTriggerEmptyFilterName = "LogTrigger - empty filter name" + solanaLogTriggerEmptyIDL = "LogTrigger - empty IDL" + solanaLogTriggerConflictSubkeys = "LogTrigger - conflicting subkey filters" + solanaLogTriggerInvalidCPIDest = "LogTrigger - invalid CPI destination address" +) + +const ( + expectedSolanaInvalidTriggerAddress = "invalid address length: expected 32 bytes, got %d" + expectedSolanaEmptyEventName = "event name cannot be empty" + expectedSolanaEmptyFilterName = "filter name cannot be empty" + expectedSolanaEmptyIDL = "event idl json cannot be empty" + expectedSolanaConflictingSubkeys = "subkey 0 has conflicting equality filters" + expectedSolanaInvalidCPIDest = "invalid cpi filter destination address length" +) + +// solanaLogReadTestProgramID is the log_read_test program deployed by the Solana topology, also +// used by the Solana log trigger smoke test. The negative cases only need a well-formed address, +// but using a real program keeps every field other than the one under test realistic. +const solanaLogReadTestProgramID = "J1zQwrBNBngz26jRPNWsUSZMHJwBwpkoDitXRV95LdK4" + +// Each of these cases costs a whole workflow deployment, so the table is kept to exactly one +// case per branch of the capability's filter validation. +var solanaNegativeTestsLogTrigger = []solanaNegativeTest{ + // LogTrigger - address that is not a 32-byte public key + {"31 bytes (short) address", solanaShortKey, solanaLogTriggerInvalidAddress, invalidTriggerAddressError(31)}, + // LogTrigger - filter fields that must be set + {"no event name", "", solanaLogTriggerEmptyEventName, expectedSolanaEmptyEventName}, + {"no filter name", "", solanaLogTriggerEmptyFilterName, expectedSolanaEmptyFilterName}, + {"no contract IDL", "", solanaLogTriggerEmptyIDL, expectedSolanaEmptyIDL}, + // LogTrigger - a subkey cannot be required to equal two different values at once + {"two equality filters on one subkey", "", solanaLogTriggerConflictSubkeys, expectedSolanaConflictingSubkeys}, + // LogTrigger - the CPI destination address is a public key too + {"31 bytes (short) CPI address", solanaShortKey, solanaLogTriggerInvalidCPIDest, expectedSolanaInvalidCPIDest}, +} + +// SolanaLogTriggerFailsTest deploys the Solana log trigger negative workflow for a single case. +// The trigger is rejected during registration, so the workflow engine never initialises and the +// failure is asserted on the engine init error rather than on a user log. +func SolanaLogTriggerFailsTest(t *testing.T, testEnv *ttypes.TestEnvironment, solanaNegativeTest solanaNegativeTest) { + testLogger := framework.L + + solChain := mustSolanaChainInEnv(t, testEnv) + chainSelector := solChain.ChainSelector() + + userLogsCh, baseMessageCh := startSolanaChipSink(t, testLogger) + // drain user logs channel in the background, we are not asserting anything on it + t_helpers.IgnoreUserLogs(t.Context(), userLogsCh) + + testLogger.Info().Msg("Creating Solana LogTrigger Fail workflow configuration...") + workflowConfig := sollogtrigger_negative_config.Config{ + ChainSelector: chainSelector, + FunctionToTest: solanaNegativeTest.functionToTest, + InvalidInput: solanaNegativeTest.invalidInput, + ProgramID: solanago.MustPublicKeyFromBase58(solanaLogReadTestProgramID), + } + + workflowName := fmt.Sprintf("sol-logtrigger-fail-workflow-%d-%04d", chainSelector, rand.Intn(10000)) + workflowID := t_helpers.CompileAndDeployWorkflow( + t, + testEnv, + testLogger, + workflowName, + &workflowConfig, + solanaLogTriggerNegativeWorkflowFile, + // the log trigger lives on the capabilities DON, so it needs workflow artifacts copied there + t_helpers.WithArtifactCopyDONTypes(crelib.WorkflowDON, crelib.CapabilitiesDON), + ) + + _ = t_helpers.WatchBaseMessages( + t, + testLogger, + baseMessageCh, + t_helpers.WorkflowEngineInitErrorLog, + 2*time.Minute, + t_helpers.WithBaseMessageWorkflowID(workflowID), + t_helpers.WithBaseMessageLabelContains("err", solanaNegativeTest.expectedError), + ) + testLogger.Info().Msgf("Solana LogTrigger Fail test successfully completed for test case %s", solanaNegativeTest.name) +} + +// ─── shared helpers ─────────────────────────────────────────────────────────── + +// toSolanaReadCases converts the test tables into the workflow's case list. A case with no +// expected error accepts an error or an empty response, see solanaNegativeTest. +func toSolanaReadCases(tests []solanaNegativeTest) []solread_negative_config.Case { + cases := make([]solread_negative_config.Case, 0, len(tests)) + for _, test := range tests { + outcome := solread_negative_config.OutcomeError + if test.expectedError == acceptErrorOrEmpty { + outcome = solread_negative_config.OutcomeErrorOrEmpty + } + + cases = append(cases, solread_negative_config.Case{ + Name: test.name, + FunctionToTest: test.functionToTest, + InvalidInput: test.invalidInput, + Outcome: outcome, + ExpectedError: test.expectedError, + }) + } + return cases +} + +func toSolanaWriteCases(tests []solanaNegativeTest) []solwrite_negative_config.Case { + cases := make([]solwrite_negative_config.Case, 0, len(tests)) + for _, test := range tests { + cases = append(cases, solwrite_negative_config.Case{ + Name: test.name, + FunctionToTest: test.functionToTest, + InvalidInput: test.invalidInput, + ExpectedError: test.expectedError, + }) + } + return cases +} + +// requireSolanaBatchFitsCallLimit fails the test before the environment is booted, so that a +// batch grown past what one workflow execution may call is reported immediately instead of as a +// call-limit error two minutes into the run. +func requireSolanaBatchFitsCallLimit(t *testing.T, batchName string, cases, limit int, calls string) { + t.Helper() + + require.LessOrEqualf(t, cases, limit, + "batch %q has %d cases, which is over the %d %s a single workflow execution is allowed - split the batch", + batchName, cases, limit, calls) +} + +func concatSolanaTests(tables ...[]solanaNegativeTest) []solanaNegativeTest { + var all []solanaNegativeTest + for _, table := range tables { + all = append(all, table...) + } + return all +} + +// batchNeedsNonExecutableAccount reports whether the batch contains the case that needs an +// existing, non-executable account, so that funding one is skipped for the batches that do not. +func batchNeedsNonExecutableAccount(cases []solwrite_negative_config.Case) bool { + for _, testCase := range cases { + if testCase.FunctionToTest == solanaWriteReportNonExecutableReceiver { + return true + } + } + return false +} + +// solanaReadBatchPassedLog and solanaWriteBatchPassedLog must match the log the workflows emit +// once every case of a batch passed. +func solanaReadBatchPassedLog(batchName string, cases int) string { + return fmt.Sprintf("Solana read negative batch '%s' passed (%d cases)", batchName, cases) +} + +func solanaWriteBatchPassedLog(batchName string, cases int) string { + return fmt.Sprintf("Solana write negative batch '%s' passed (%d cases)", batchName, cases) +} + +// startSolanaChipSink boots a per-test CHiP sink and returns the channels the workflow logs and +// engine messages are published to. +func startSolanaChipSink(t *testing.T, testLogger zerolog.Logger) (chan *workflowevents.UserLogs, chan *commonevents.BaseMessage) { + t.Helper() + + userLogsCh := make(chan *workflowevents.UserLogs, 1000) + baseMessageCh := make(chan *commonevents.BaseMessage, 1000) + + server := t_helpers.StartChipTestSink(t, t_helpers.GetPublishFn(testLogger, userLogsCh, baseMessageCh)) + + t.Cleanup(func() { + // can't use t.Context() here because it will have been cancelled before the cleanup function is called + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + t_helpers.ShutdownChipSinkWithDrain( + ctx, + server, + userLogsCh, + baseMessageCh, + ) + }) + + return userLogsCh, baseMessageCh +} + +func invalidPublicKeyError(gotBytes int) string { + return fmt.Sprintf(expectedSolanaInvalidPublicKey, gotBytes) +} + +func invalidSignatureError(gotBytes int) string { + return fmt.Sprintf(expectedSolanaInvalidSignature, gotBytes) +} + +func invalidTriggerAddressError(gotBytes int) string { + return fmt.Sprintf(expectedSolanaInvalidTriggerAddress, gotBytes) +} + +// mustSolanaChainInEnv returns the single Solana chain of the test environment. +func mustSolanaChainInEnv(t *testing.T, testEnv *ttypes.TestEnvironment) *solana.Blockchain { + t.Helper() + + var solChain *solana.Blockchain + for _, blockchain := range testEnv.CreEnvironment.Blockchains { + if !blockchain.IsFamily(chainselectors.FamilySolana) { + continue + } + require.IsType(t, &solana.Blockchain{}, blockchain, "expected Solana blockchain type") + // we assume we always have just 1 Solana chain + solChain = blockchain.(*solana.Blockchain) + break + } + require.NotNil(t, solChain, "Solana blockchain not found in test environment") + + return solChain +} + +// mustGetSolanaContract reads a deployed Solana program/account address from the datastore. +func mustGetSolanaContract(t *testing.T, ds datastore.DataStore, chainSelector uint64, contractType datastore.ContractType) solanago.PublicKey { + t.Helper() + + key := datastore.NewAddressRefKey( + chainSelector, + contractType, + semver.MustParse("1.0.0"), + ks_sol.DefaultForwarderQualifier, + ) + contract, err := ds.Addresses().Get(key) + require.NoError(t, err, "failed to get '%s' address from the datastore", contractType) + + return solanago.MustPublicKeyFromBase58(contract.Address) +} + +// mustCreateFundedSolanaAccount funds a fresh address so that it exists on-chain as a +// system-owned, non-executable account. +func mustCreateFundedSolanaAccount(t *testing.T, testLogger zerolog.Logger, solChain blockchains.Blockchain) solanago.PublicKey { + t.Helper() + + addresses, err := t_helpers.CreateAndFundAddressesSolana(t, testLogger, 1, solanaFundingAmount, solChain) + require.NoError(t, err, "failed to create and fund a Solana address") + require.Len(t, addresses, 1, "failed to create the correct number of Solana addresses") + + return addresses[0] +} diff --git a/system-tests/tests/test-helpers/t_helpers.go b/system-tests/tests/test-helpers/t_helpers.go index e54ec97ff77..c593be0a3e9 100644 --- a/system-tests/tests/test-helpers/t_helpers.go +++ b/system-tests/tests/test-helpers/t_helpers.go @@ -69,6 +69,9 @@ import ( logtrigger_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative/config" http_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/http/config" httpaction_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/httpaction-negative/config" + sollogtrigger_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/sollogtrigger-negative/config" + solread_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solread-negative/config" + solwrite_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/solana/solwrite-negative/config" aptoswrite_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite/config" aptoswriteroundtrip_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config" evmread_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/evmread/config" @@ -333,6 +336,9 @@ type WorkflowConfig interface { httpaction_smoke_config.Config | httpaction_negative_config.Config | solwrite_config.Config | + solwrite_negative_config.Config | + solread_negative_config.Config | + sollogtrigger_negative_config.Config | sollogtrigger_config.Config | vaultsecret_config.Config | solread_config.Config @@ -598,6 +604,21 @@ func workflowConfigFactory[T WorkflowConfig](t *testing.T, testLogger zerolog.Lo workflowConfigFilePath = workflowCfgFilePath require.NoError(t, configErr, "failed to create solwrite workflow config file") testLogger.Info().Msg("Solana write workflow config file created.") + case *solwrite_negative_config.Config: + workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) + workflowConfigFilePath = workflowCfgFilePath + require.NoError(t, configErr, "failed to create solwrite-negative workflow config file") + testLogger.Info().Msg("Solana write negative workflow config file created.") + case *solread_negative_config.Config: + workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) + workflowConfigFilePath = workflowCfgFilePath + require.NoError(t, configErr, "failed to create solread-negative workflow config file") + testLogger.Info().Msg("Solana read negative workflow config file created.") + case *sollogtrigger_negative_config.Config: + workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) + workflowConfigFilePath = workflowCfgFilePath + require.NoError(t, configErr, "failed to create sollogtrigger-negative workflow config file") + testLogger.Info().Msg("Solana log trigger negative workflow config file created.") case *sollogtrigger_config.Config: workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) workflowConfigFilePath = workflowCfgFilePath