From 2b9a7bf397b1e380d73e65f0affa08a5e40d263e Mon Sep 17 00:00:00 2001 From: Prazwal Ratti Date: Fri, 24 Jul 2026 00:56:30 +0530 Subject: [PATCH] fix(pipeline): accept literal address in ethtx from field, issue 21768 --- core/services/pipeline/getters.go | 11 +++++ core/services/pipeline/getters_test.go | 11 +++++ core/services/pipeline/task.eth_tx_test.go | 44 ++++++++++++++++++++ core/services/pipeline/task_params.go | 16 ++++---- core/services/pipeline/task_params_test.go | 48 ++++++++++++++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) diff --git a/core/services/pipeline/getters.go b/core/services/pipeline/getters.go index 2da85d64289..4a585129192 100644 --- a/core/services/pipeline/getters.go +++ b/core/services/pipeline/getters.go @@ -3,7 +3,9 @@ package pipeline import ( "bytes" "encoding/json" + stderrors "errors" "fmt" + "io" "strings" "time" @@ -133,6 +135,15 @@ func JSONWithVarExprs(jsExpr string, vars Vars, allowErrors bool) GetterFunc { if err := jd.Decode(&val); err != nil { return nil, errors.Wrapf(ErrBadInput, "while unmarshalling JSON: %v; js: %s", err, string(replaced)) } + // Reject inputs the decoder only partially consumed. json.Decoder reads a + // single JSON value from the stream and silently ignores trailing bytes, + // so an Ethereum address like "0x8829..." decodes as the JSON number 0 + // with the rest discarded. We signal ErrParameterEmpty (not ErrBadInput) + // so ResolveParam falls through to the next getter (e.g. NonemptyString), + // letting a literal address be handled as intended. See issue #21768. + if _, err := jd.Token(); !stderrors.Is(err, io.EOF) { + return nil, errors.Wrapf(ErrParameterEmpty, "input is not a single JSON value; js: %s", string(replaced)) + } reinterpreted, err := jsonserializable.ReinterpretJSONNumbers(val) if err != nil { return nil, errors.Wrapf(ErrBadInput, "while processing json.Number: %v; js: %s", err, string(replaced)) diff --git a/core/services/pipeline/getters_test.go b/core/services/pipeline/getters_test.go index 11a8a92c720..2043b50622c 100644 --- a/core/services/pipeline/getters_test.go +++ b/core/services/pipeline/getters_test.go @@ -95,6 +95,17 @@ func TestGetters_JSONWithVarExprs(t *testing.T) { {`{ "$(foo.bar)": $(zet) }`, "value", 123, pipeline.ErrBadInput, false}, {`{ "x": { "__chainlink_key_path__": 0 } }`, "", nil, pipeline.ErrBadInput, false}, {`{ "e": $(err)`, "e", nil, pipeline.ErrBadInput, false}, + // #21768: inputs the JSON decoder only partially consumes must be + // rejected as ErrParameterEmpty so ResolveParam falls through to the + // next getter (e.g. NonemptyString). A literal Ethereum address reads + // as the JSON number 0 with the trailing characters silently dropped. + {`0x52908400098527886E0F7030069857D2E4169EE7`, "", nil, pipeline.ErrParameterEmpty, false}, + {`0xdeadbeef`, "", nil, pipeline.ErrParameterEmpty, false}, + {`123abc`, "", nil, pipeline.ErrParameterEmpty, false}, + {`0 0`, "", nil, pipeline.ErrParameterEmpty, false}, + {`{"a":1} extra`, "", nil, pipeline.ErrParameterEmpty, false}, + {`[1,2] junk`, "", nil, pipeline.ErrParameterEmpty, false}, + {`true false`, "", nil, pipeline.ErrParameterEmpty, false}, } for _, test := range tests { diff --git a/core/services/pipeline/task.eth_tx_test.go b/core/services/pipeline/task.eth_tx_test.go index ba59796441b..0efe6f40525 100644 --- a/core/services/pipeline/task.eth_tx_test.go +++ b/core/services/pipeline/task.eth_tx_test.go @@ -100,6 +100,50 @@ func TestETHTxTask(t *testing.T) { }, nil, nil, "", pipeline.RunInfo{}, }, + { + // #21768: a literal address in `from` (not wrapped in a JSON array) + // used to fail with "AddressSliceParam: cannot convert int64". + "happy (literal from address, #21768)", + `0x882969652440ccf14a5dbb9bd53eb21cb1e11e5c`, + "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF", + "foobar", + "12345", + `{ "jobID": 321, "requestID": "0x5198616554d738d9485d1a7cf53b2f33e09c3bbc8fe9ac0020bd672cd2bc15d2", "requestTxHash": "0xc524fafafcaec40652b1f84fca09c231185437d008d195fccf2f51e64b7062f8" }`, + `0`, + testutils.FixtureChainID.String(), + `{"CheckerType": "vrf_v2", "VRFCoordinatorAddress": "0x2E396ecbc8223Ebc16EC45136228AE5EDB649943"}`, + nil, + false, + pipeline.NewVarsFrom(nil), + nil, + func(keyStore *keystoremocks.Eth, txManager *txmmocks.MockEvmTxManager) { + data := []byte("foobar") + gasLimit := uint64(12345) + jobID := int32(321) + addr := common.HexToAddress("0x2E396ecbc8223Ebc16EC45136228AE5EDB649943") + txMeta := &txmgr.TxMeta{ + JobID: &jobID, + RequestID: &reqID, + RequestTxHash: &reqTxHash, + FailOnRevert: null.BoolFrom(false), + } + keyStore.On("GetRoundRobinAddress", mock.Anything, testutils.FixtureChainID, from).Return(from, nil) + txManager.On("CreateTransaction", mock.Anything, txmgr.TxRequest{ + FromAddress: from, + ToAddress: to, + EncodedPayload: data, + FeeLimit: gasLimit, + Meta: txMeta, + Strategy: txmgrcommon.NewSendEveryStrategy(), + Checker: txmgr.TransmitCheckerSpec{ + CheckerType: txmgr.TransmitCheckerTypeVRFV2, + VRFCoordinatorAddress: &addr, + }, + SignalCallback: true, + }).Return(txmgr.Tx{}, nil) + }, + nil, nil, "", pipeline.RunInfo{}, + }, { "happy (with vars)", `[ $(fromAddr) ]`, diff --git a/core/services/pipeline/task_params.go b/core/services/pipeline/task_params.go index 485cbb2e6c7..4001de108b9 100644 --- a/core/services/pipeline/task_params.go +++ b/core/services/pipeline/task_params.go @@ -666,14 +666,16 @@ func (s *AddressSliceParam) UnmarshalPipelineParam(val any) error { case []common.Address: asp = v case string: - err := json.Unmarshal([]byte(v), &asp) - if err != nil { - return errors.Wrapf(ErrBadInput, "AddressSliceParam: %v", err) - } + return s.UnmarshalPipelineParam([]byte(v)) case []byte: - err := json.Unmarshal(v, &asp) - if err != nil { - return errors.Wrapf(ErrBadInput, "AddressSliceParam: %v", err) + if err := json.Unmarshal(v, &asp); err != nil { + // Not a JSON array. Fall back to a single literal address, e.g. + // `from="0x8829..."`, which is not valid JSON on its own. See #21768. + var addr AddressParam + if aerr := addr.UnmarshalPipelineParam(v); aerr != nil { + return errors.Wrapf(ErrBadInput, "AddressSliceParam: %v", err) + } + asp = append(asp, common.Address(addr)) } case []any: for _, a := range v { diff --git a/core/services/pipeline/task_params_test.go b/core/services/pipeline/task_params_test.go index 71ca8d6f2cc..d31b7c3e9ad 100644 --- a/core/services/pipeline/task_params_test.go +++ b/core/services/pipeline/task_params_test.go @@ -178,8 +178,13 @@ func TestAddressSliceParam_UnmarshalPipelineParam(t *testing.T) { {"[]interface{} with []byte", []any{[]byte(addr1.String()), []byte(addr2.String())}, expected, nil}, {"nil", nil, pipeline.AddressSliceParam(nil), nil}, + // #21768: a single literal address (not a JSON array) must be accepted. + {"single literal address string", "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", pipeline.AddressSliceParam{addr1}, nil}, + {"single literal address []byte", []byte("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), pipeline.AddressSliceParam{addr1}, nil}, + {"bad json", `[ "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" "0xcafebabecafebabecafebabecafebabecafebabe" ]`, nil, pipeline.ErrBadInput}, {"[]interface{} with bad types", []any{123, true}, nil, pipeline.ErrBadInput}, + {"garbage string", "not-an-address", nil, pipeline.ErrBadInput}, } for _, test := range tests { @@ -194,6 +199,49 @@ func TestAddressSliceParam_UnmarshalPipelineParam(t *testing.T) { } } +// TestAddressSliceParam_ResolveFromGetterChain_Issue21768 drives the exact +// getter chain the ethtx task uses to resolve its `from` field (see +// task.eth_tx.go: From(VarExpr, JSONWithVarExprs, NonemptyString, nil)). It +// uses the real exported resolver functions with no mocks, DB, or stubs, so it +// pins down the #21768 regression at the layer where the bug actually lived: +// a literal address like "0x8829..." was decoded by json.Decoder as the number +// 0, surfaced as int64(0), and rejected downstream as "cannot convert int64". +func TestAddressSliceParam_ResolveFromGetterChain_Issue21768(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0x882969652440ccf14a5dbb9bd53eb21cb1e11e5c") + vars := pipeline.NewVarsFrom(map[string]any{ + "fromAddr": addr, + "fromAddrs": []common.Address{addr}, + }) + + tests := []struct { + name string + from string + expected pipeline.AddressSliceParam + }{ + {"literal address (regression #21768)", "0x882969652440ccf14a5dbb9bd53eb21cb1e11e5c", pipeline.AddressSliceParam{addr}}, + {"literal address with surrounding spaces", " 0x882969652440ccf14a5dbb9bd53eb21cb1e11e5c ", pipeline.AddressSliceParam{addr}}, + {"json array (already worked)", `[ "0x882969652440ccf14a5dbb9bd53eb21cb1e11e5c" ]`, pipeline.AddressSliceParam{addr}}, + {"json array with var expr", `[ $(fromAddr) ]`, pipeline.AddressSliceParam{addr}}, + {"variable expression slice", "$(fromAddrs)", pipeline.AddressSliceParam{addr}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var fromAddrs pipeline.AddressSliceParam + err := pipeline.ResolveParam(&fromAddrs, pipeline.From( + pipeline.VarExpr(test.from, vars), + pipeline.JSONWithVarExprs(test.from, vars, false), + pipeline.NonemptyString(test.from), + nil, + )) + require.NoError(t, err) + require.Equal(t, test.expected, fromAddrs) + }) + } +} + func TestUint64Param_UnmarshalPipelineParam(t *testing.T) { t.Parallel()