Repository: hyperledger/firefly
Pull request #844 (2022-05-31 15:46:41)

Allow extra options to pass through to blockchain connectors
This PR adds the ability to pass additional options through to blockchain connectors. This is useful for controlling other parameters when submitting a blockchain transaction besides the specific arguments that a smart contract function takes. For example, this can now be used to set a custom gas price

<img width="2537" alt="Screen Shot 2022-05-31 at 11 39 25 AM" src="https://user-images.githubusercontent.com/2530008/171214094-4fb58412-eb32-4748-a223-5bd37d007be6.png">

Anywhere that a smart contract can be invoked or queried a new `options` JSON object (treated as a `map[string]interface{}` in Go) can be optionally specified. Any fields in this structure will be included in the request to the blockchain connector.

Overriding existing fields in the request is **not** allowed. For example, if using Ethconnect, the following request body would be rejected because `headers` is a field that FireFly itself needs to set:

```json
{
  "input": {
    "newValue": "x"
  },
  "options": {
    "headers": "not allowed"
  }
}
```
This would result in an HTTP 400 with an error message indicating which option was not allowed.


Patch:
diff --git a/docs/reference/config.md b/docs/reference/config.md
index ec07ecc10..3f248da97 100644
--- a/docs/reference/config.md
+++ b/docs/reference/config.md
@@ -1,8 +1,8 @@
 ---
 layout: default
-title: Configuration Reference
+title: pages.reference
 parent: Reference
-nav_order: 3
+nav_order: 2
 ---
 
 # Configuration Reference
diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml
index b07645700..5f5247a7c 100644
--- a/docs/swagger/swagger.yaml
+++ b/docs/swagger/swagger.yaml
@@ -597,6 +597,13 @@ paths:
                 location:
                   description: A blockchain specific contract identifier. For example
                     an Ethereum contract address, or a Fabric chaincode name and channel
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -1152,6 +1159,13 @@ paths:
                 location:
                   description: A blockchain specific contract identifier. For example
                     an Ethereum contract address, or a Fabric chaincode name and channel
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -3094,6 +3108,13 @@ paths:
                 methodPath:
                   description: The pathname of the method on the specified FFI
                   type: string
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -3861,6 +3882,13 @@ paths:
                 methodPath:
                   description: The pathname of the method on the specified FFI
                   type: string
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -9292,6 +9320,13 @@ paths:
                 methodPath:
                   description: The pathname of the method on the specified FFI
                   type: string
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -9970,6 +10005,13 @@ paths:
                 methodPath:
                   description: The pathname of the method on the specified FFI
                   type: string
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -11996,6 +12038,13 @@ paths:
                 methodPath:
                   description: The pathname of the method on the specified FFI
                   type: string
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
@@ -12798,6 +12847,13 @@ paths:
                 methodPath:
                   description: The pathname of the method on the specified FFI
                   type: string
+                options:
+                  additionalProperties:
+                    description: A map of named inputs that will be passed through
+                      to the blockchain connector
+                  description: A map of named inputs that will be passed through to
+                    the blockchain connector
+                  type: object
               type: object
       responses:
         "200":
diff --git a/go.sum b/go.sum
index 652bbf1b1..4d5ad3938 100644
--- a/go.sum
+++ b/go.sum
@@ -635,8 +635,6 @@ github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKEN
 github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
 github.com/hashicorp/serf v0.9.7/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/hyperledger/firefly-common v0.1.5 h1:66RXEAc/dsSEYPRye3G48Lk9sN+AmYGPf1FkrkbQGWs=
-github.com/hyperledger/firefly-common v0.1.5/go.mod h1:qGy7i8eWlE8Ed7jFn2Hpn8bYaflL204j4NJB1mAfTms=
 github.com/hyperledger/firefly-common v0.1.6 h1:I6t7N5D3YlaPSGknYF2xLurfEO+UQsHV4VFYeR/3UI4=
 github.com/hyperledger/firefly-common v0.1.6/go.mod h1:qGy7i8eWlE8Ed7jFn2Hpn8bYaflL204j4NJB1mAfTms=
 github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
diff --git a/internal/blockchain/ethereum/ethereum.go b/internal/blockchain/ethereum/ethereum.go
index aed2f12dc..25627e061 100644
--- a/internal/blockchain/ethereum/ethereum.go
+++ b/internal/blockchain/ethereum/ethereum.go
@@ -135,14 +135,6 @@ type ABIElementMarshaling struct {
 	Outputs         []ABIArgumentMarshaling `json:"outputs"`
 }
 
-type EthconnectMessageRequest struct {
-	Headers EthconnectMessageHeaders `json:"headers,omitempty"`
-	To      string                   `json:"to"`
-	From    string                   `json:"from,omitempty"`
-	Method  ABIElementMarshaling     `json:"method"`
-	Params  []interface{}            `json:"params"`
-}
-
 type EthconnectMessageHeaders struct {
 	Type string `json:"type,omitempty"`
 	ID   string `json:"id,omitempty"`
@@ -602,19 +594,41 @@ func wrapError(ctx context.Context, errRes *ethError, res *resty.Response, err e
 	return ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgEthconnectRESTErr)
 }
 
-func (e *Ethereum) invokeContractMethod(ctx context.Context, address, signingKey string, abi ABIElementMarshaling, requestID string, input []interface{}) error {
+func (e *Ethereum) buildEthconnectRequestBody(ctx context.Context, messageType, address, signingKey string, abi ABIElementMarshaling, requestID string, input []interface{}, options map[string]interface{}) (map[string]interface{}, error) {
+	headers := EthconnectMessageHeaders{
+		Type: messageType,
+	}
+	if requestID != "" {
+		headers.ID = requestID
+	}
+	body := map[string]interface{}{
+		"headers": headers,
+		"to":      address,
+		"method":  abi,
+		"params":  input,
+	}
+	if signingKey != "" {
+		body["from"] = signingKey
+	}
+	for k, v := range options {
+		// Set the new field if it's not already set. Do not allow overriding of existing fields
+		if _, ok := body[k]; !ok {
+			body[k] = v
+		} else {
+			return nil, i18n.NewError(ctx, coremsgs.MsgOverrideExistingFieldCustomOption, k)
+		}
+	}
+	return body, nil
+}
+
+func (e *Ethereum) invokeContractMethod(ctx context.Context, address, signingKey string, abi ABIElementMarshaling, requestID string, input []interface{}, options map[string]interface{}) error {
 	if e.metrics.IsMetricsEnabled() {
 		e.metrics.BlockchainTransaction(address, abi.Name)
 	}
-	body := EthconnectMessageRequest{
-		Headers: EthconnectMessageHeaders{
-			Type: "SendTransaction",
-			ID:   requestID,
-		},
-		From:   signingKey,
-		To:     address,
-		Method: abi,
-		Params: input,
+	messageType := "SendTransaction"
+	body, err := e.buildEthconnectRequestBody(ctx, messageType, address, signingKey, abi, requestID, input, options)
+	if err != nil {
+		return err
 	}
 	client := e.fftmClient
 	if client == nil {
@@ -632,17 +646,14 @@ func (e *Ethereum) invokeContractMethod(ctx context.Context, address, signingKey
 	return nil
 }
 
-func (e *Ethereum) queryContractMethod(ctx context.Context, address string, abi ABIElementMarshaling, input []interface{}) (*resty.Response, error) {
+func (e *Ethereum) queryContractMethod(ctx context.Context, address string, abi ABIElementMarshaling, input []interface{}, options map[string]interface{}) (*resty.Response, error) {
 	if e.metrics.IsMetricsEnabled() {
 		e.metrics.BlockchainQuery(address, abi.Name)
 	}
-	body := EthconnectMessageRequest{
-		Headers: EthconnectMessageHeaders{
-			Type: "Query",
-		},
-		To:     address,
-		Method: abi,
-		Params: input,
+	messageType := "Query"
+	body, err := e.buildEthconnectRequestBody(ctx, messageType, address, "", abi, "", input, options)
+	if err != nil {
+		return nil, err
 	}
 	return e.client.R().
 		SetContext(ctx).
@@ -668,7 +679,7 @@ func (e *Ethereum) SubmitBatchPin(ctx context.Context, operationID *fftypes.UUID
 	e.fireflyMux.Lock()
 	address := e.fireflyContract
 	e.fireflyMux.Unlock()
-	return e.invokeContractMethod(ctx, address, signingKey, batchPinMethodABI, operationID.String(), input)
+	return e.invokeContractMethod(ctx, address, signingKey, batchPinMethodABI, operationID.String(), input, nil)
 }
 
 func (e *Ethereum) SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.NetworkActionType) error {
@@ -682,10 +693,10 @@ func (e *Ethereum) SubmitNetworkAction(ctx context.Context, operationID *fftypes
 	e.fireflyMux.Lock()
 	address := e.fireflyContract
 	e.fireflyMux.Unlock()
-	return e.invokeContractMethod(ctx, address, signingKey, batchPinMethodABI, operationID.String(), input)
+	return e.invokeContractMethod(ctx, address, signingKey, batchPinMethodABI, operationID.String(), input, nil)
 }
 
-func (e *Ethereum) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error {
+func (e *Ethereum) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) error {
 	ethereumLocation, err := parseContractLocation(ctx, location)
 	if err != nil {
 		return err
@@ -694,10 +705,10 @@ func (e *Ethereum) InvokeContract(ctx context.Context, operationID *fftypes.UUID
 	if err != nil {
 		return err
 	}
-	return e.invokeContractMethod(ctx, ethereumLocation.Address, signingKey, abi, operationID.String(), orderedInput)
+	return e.invokeContractMethod(ctx, ethereumLocation.Address, signingKey, abi, operationID.String(), orderedInput, options)
 }
 
-func (e *Ethereum) QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) (interface{}, error) {
+func (e *Ethereum) QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) (interface{}, error) {
 	ethereumLocation, err := parseContractLocation(ctx, location)
 	if err != nil {
 		return nil, err
@@ -706,7 +717,7 @@ func (e *Ethereum) QueryContract(ctx context.Context, location *fftypes.JSONAny,
 	if err != nil {
 		return nil, err
 	}
-	res, err := e.queryContractMethod(ctx, ethereumLocation.Address, abi, orderedInput)
+	res, err := e.queryContractMethod(ctx, ethereumLocation.Address, abi, orderedInput, options)
 	if err != nil || !res.IsSuccess() {
 		return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgEthconnectRESTErr)
 	}
diff --git a/internal/blockchain/fabric/fabric.go b/internal/blockchain/fabric/fabric.go
index ede895ec9..edd7dfd28 100644
--- a/internal/blockchain/fabric/fabric.go
+++ b/internal/blockchain/fabric/fabric.go
@@ -97,12 +97,6 @@ type PrefixItem struct {
 	Type string `json:"type"`
 }
 
-type fabTxNamedInput struct {
-	Headers *fabTxInputHeaders `json:"headers"`
-	Func    string             `json:"func"`
-	Args    map[string]string  `json:"args"`
-}
-
 type fabQueryNamedOutput struct {
 	Headers *fabTxInputHeaders `json:"headers"`
 	Result  interface{}        `json:"result"`
@@ -578,27 +572,16 @@ func wrapError(ctx context.Context, errRes *fabError, res *resty.Response, err e
 	return ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgFabconnectRESTErr)
 }
 
-func (f *Fabric) invokeContractMethod(ctx context.Context, channel, chaincode, methodName, signingKey, requestID string, prefixItems []*PrefixItem, input map[string]string) error {
-	in := &fabTxNamedInput{
-		Headers: &fabTxInputHeaders{
-			ID: requestID,
-			PayloadSchema: &PayloadSchema{
-				Type:        "array",
-				PrefixItems: prefixItems,
-			},
-			Channel:   channel,
-			Chaincode: chaincode,
-			Signer:    getUserName(signingKey),
-		},
-		Func: methodName,
-		Args: input,
+func (f *Fabric) invokeContractMethod(ctx context.Context, channel, chaincode, methodName, signingKey, requestID string, prefixItems []*PrefixItem, input map[string]interface{}, options map[string]interface{}) error {
+	body, err := f.buildFabconnectRequestBody(ctx, channel, chaincode, methodName, signingKey, requestID, prefixItems, input, options)
+	if err != nil {
+		return err
 	}
-
 	var resErr fabError
 	res, err := f.client.R().
 		SetContext(ctx).
-		SetBody(in).
 		SetHeader("x-firefly-sync", "false").
+		SetBody(body).
 		SetError(&resErr).
 		Post("/transactions")
 	if err != nil || !res.IsSuccess() {
@@ -645,7 +628,7 @@ func (f *Fabric) SubmitBatchPin(ctx context.Context, operationID *fftypes.UUID,
 	f.fireflyMux.Lock()
 	chaincode := f.fireflyChaincode
 	f.fireflyMux.Unlock()
-	return f.invokeContractMethod(ctx, f.defaultChannel, chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input)
+	return f.invokeContractMethod(ctx, f.defaultChannel, chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input, nil)
 }
 
 func (f *Fabric) SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.NetworkActionType) error {
@@ -660,16 +643,41 @@ func (f *Fabric) SubmitNetworkAction(ctx context.Context, operationID *fftypes.U
 	f.fireflyMux.Lock()
 	chaincode := f.fireflyChaincode
 	f.fireflyMux.Unlock()
-	return f.invokeContractMethod(ctx, f.defaultChannel, chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input)
+	return f.invokeContractMethod(ctx, f.defaultChannel, chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input, nil)
 }
 
-func (f *Fabric) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error {
+func (f *Fabric) buildFabconnectRequestBody(ctx context.Context, channel, chaincode, methodName, signingKey, requestID string, prefixItems []*PrefixItem, input map[string]interface{}, options map[string]interface{}) (map[string]interface{}, error) {
 	// All arguments must be JSON serialized
 	args, err := jsonEncodeInput(input)
 	if err != nil {
-		return i18n.WrapError(ctx, err, i18n.MsgJSONObjectParseFailed, "params")
+		return nil, i18n.WrapError(ctx, err, i18n.MsgJSONObjectParseFailed, "params")
+	}
+	body := map[string]interface{}{
+		"headers": &fabTxInputHeaders{
+			ID: requestID,
+			PayloadSchema: &PayloadSchema{
+				Type:        "array",
+				PrefixItems: prefixItems,
+			},
+			Channel:   channel,
+			Chaincode: chaincode,
+			Signer:    getUserName(signingKey),
+		},
+		"func": methodName,
+		"args": args,
 	}
+	for k, v := range options {
+		// Set the new field if it's not already set. Do not allow overriding of existing fields
+		if _, ok := body[k]; !ok {
+			body[k] = v
+		} else {
+			return nil, i18n.NewError(ctx, coremsgs.MsgOverrideExistingFieldCustomOption, k)
+		}
+	}
+	return body, nil
+}
 
+func (f *Fabric) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) error {
 	fabricOnChainLocation, err := parseContractLocation(ctx, location)
 	if err != nil {
 		return err
@@ -689,16 +697,10 @@ func (f *Fabric) InvokeContract(ctx context.Context, operationID *fftypes.UUID,
 		}
 	}
 
-	return f.invokeContractMethod(ctx, fabricOnChainLocation.Channel, fabricOnChainLocation.Chaincode, method.Name, signingKey, operationID.String(), prefixItems, args)
+	return f.invokeContractMethod(ctx, fabricOnChainLocation.Channel, fabricOnChainLocation.Chaincode, method.Name, signingKey, operationID.String(), prefixItems, input, options)
 }
 
-func (f *Fabric) QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) (interface{}, error) {
-	// All arguments must be JSON serialized
-	args, err := jsonEncodeInput(input)
-	if err != nil {
-		return nil, i18n.WrapError(ctx, err, i18n.MsgJSONObjectParseFailed, "params")
-	}
-
+func (f *Fabric) QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) (interface{}, error) {
 	fabricOnChainLocation, err := parseContractLocation(ctx, location)
 	if err != nil {
 		return nil, err
@@ -713,23 +715,13 @@ func (f *Fabric) QueryContract(ctx context.Context, location *fftypes.JSONAny, m
 		}
 	}
 
-	in := &fabTxNamedInput{
-		Headers: &fabTxInputHeaders{
-			PayloadSchema: &PayloadSchema{
-				Type:        "array",
-				PrefixItems: prefixItems,
-			},
-			Channel:   fabricOnChainLocation.Channel,
-			Chaincode: fabricOnChainLocation.Chaincode,
-			Signer:    f.signer,
-		},
-		Func: method.Name,
-		Args: args,
+	body, err := f.buildFabconnectRequestBody(ctx, fabricOnChainLocation.Channel, fabricOnChainLocation.Chaincode, method.Name, f.signer, "", prefixItems, input, options)
+	if err != nil {
+		return nil, err
 	}
-
 	res, err := f.client.R().
 		SetContext(ctx).
-		SetBody(in).
+		SetBody(body).
 		Post("/query")
 
 	if err != nil || !res.IsSuccess() {
@@ -742,8 +734,8 @@ func (f *Fabric) QueryContract(ctx context.Context, location *fftypes.JSONAny, m
 	return output.Result, nil
 }
 
-func jsonEncodeInput(params map[string]interface{}) (output map[string]string, err error) {
-	output = make(map[string]string, len(params))
+func jsonEncodeInput(params map[string]interface{}) (output map[string]interface{}, err error) {
+	output = make(map[string]interface{}, len(params))
 	for field, value := range params {
 		switch v := value.(type) {
 		case string:
diff --git a/internal/contracts/manager.go b/internal/contracts/manager.go
index fbc73624f..892f44c81 100644
--- a/internal/contracts/manager.go
+++ b/internal/contracts/manager.go
@@ -255,7 +255,7 @@ func (cm *contractManager) InvokeContract(ctx context.Context, ns string, req *c
 		err = send(ctx)
 		return op, err
 	case core.CallTypeQuery:
-		return cm.blockchain.QueryContract(ctx, req.Location, req.Method, req.Input)
+		return cm.blockchain.QueryContract(ctx, req.Location, req.Method, req.Input, req.Options)
 	default:
 		panic(fmt.Sprintf("unknown call type: %s", req.Type))
 	}
diff --git a/internal/contracts/operations.go b/internal/contracts/operations.go
index 9cc42127c..f2163a7be 100644
--- a/internal/contracts/operations.go
+++ b/internal/contracts/operations.go
@@ -66,7 +66,7 @@ func (cm *contractManager) RunOperation(ctx context.Context, op *core.PreparedOp
 	switch data := op.Data.(type) {
 	case blockchainInvokeData:
 		req := data.Request
-		return nil, false, cm.blockchain.InvokeContract(ctx, op.ID, req.Key, req.Location, req.Method, req.Input)
+		return nil, false, cm.blockchain.InvokeContract(ctx, op.ID, req.Key, req.Location, req.Method, req.Input, req.Options)
 
 	default:
 		return nil, false, i18n.NewError(ctx, coremsgs.MsgOperationDataIncorrect, op.Data)
diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go
index 0b73b35fa..c1dc5fa3c 100644
--- a/internal/coremsgs/en_error_messages.go
+++ b/internal/coremsgs/en_error_messages.go
@@ -244,4 +244,5 @@ var (
 	MsgDuplicatePluginName                = ffe("FF10395", "Invalid plugin configuration - plugin with name %s already exists")
 	MsgInvalidFireFlyContractIndex        = ffe("FF10396", "No configuration found for FireFly contract at %s")
 	MsgUnrecognizedNetworkAction          = ffe("FF10397", "Unrecognized network action: %s", 400)
+	MsgOverrideExistingFieldCustomOption  = ffe("FF10398", "Cannot override existing field with custom option named '%s'", 400)
 )
diff --git a/internal/coremsgs/en_struct_descriptions.go b/internal/coremsgs/en_struct_descriptions.go
index 0b57a2649..21ad60f2d 100644
--- a/internal/coremsgs/en_struct_descriptions.go
+++ b/internal/coremsgs/en_struct_descriptions.go
@@ -585,6 +585,7 @@ var (
 	ContractCallRequestMethod     = ffm("ContractCallRequest.method", "An in-line FFI method definition for the method to invoke. Required when FFI is not specified")
 	ContractCallRequestMethodPath = ffm("ContractCallRequest.methodPath", "The pathname of the method on the specified FFI")
 	ContractCallRequestInput      = ffm("ContractCallRequest.input", "A map of named inputs. The name and type of each input must be compatible with the FFI description of the method, so that FireFly knows how to serialize it to the blockchain via the connector")
+	ContractCallRequestOptions    = ffm("ContractCallRequest.options", "A map of named inputs that will be passed through to the blockchain connector")
 
 	// WebSocketStatus field descriptions
 	WebSocketStatusEnabled     = ffm("WebSocketStatus.enabled", "Indicates whether the websockets plugin is enabled")
diff --git a/mocks/blockchainmocks/plugin.go b/mocks/blockchainmocks/plugin.go
index 9ac59f56d..3950ae24f 100644
--- a/mocks/blockchainmocks/plugin.go
+++ b/mocks/blockchainmocks/plugin.go
@@ -159,13 +159,13 @@ func (_m *Plugin) InitConfig(_a0 config.Section) {
 	_m.Called(_a0)
 }
 
-// InvokeContract provides a mock function with given fields: ctx, operationID, signingKey, location, method, input
-func (_m *Plugin) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error {
-	ret := _m.Called(ctx, operationID, signingKey, location, method, input)
+// InvokeContract provides a mock function with given fields: ctx, operationID, signingKey, location, method, input, options
+func (_m *Plugin) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) error {
+	ret := _m.Called(ctx, operationID, signingKey, location, method, input, options)
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID, string, *fftypes.JSONAny, *core.FFIMethod, map[string]interface{}) error); ok {
-		r0 = rf(ctx, operationID, signingKey, location, method, input)
+	if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID, string, *fftypes.JSONAny, *core.FFIMethod, map[string]interface{}, map[string]interface{}) error); ok {
+		r0 = rf(ctx, operationID, signingKey, location, method, input, options)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -231,13 +231,13 @@ func (_m *Plugin) NormalizeSigningKey(ctx context.Context, keyRef string) (strin
 	return r0, r1
 }
 
-// QueryContract provides a mock function with given fields: ctx, location, method, input
-func (_m *Plugin) QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) (interface{}, error) {
-	ret := _m.Called(ctx, location, method, input)
+// QueryContract provides a mock function with given fields: ctx, location, method, input, options
+func (_m *Plugin) QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) (interface{}, error) {
+	ret := _m.Called(ctx, location, method, input, options)
 
 	var r0 interface{}
-	if rf, ok := ret.Get(0).(func(context.Context, *fftypes.JSONAny, *core.FFIMethod, map[string]interface{}) interface{}); ok {
-		r0 = rf(ctx, location, method, input)
+	if rf, ok := ret.Get(0).(func(context.Context, *fftypes.JSONAny, *core.FFIMethod, map[string]interface{}, map[string]interface{}) interface{}); ok {
+		r0 = rf(ctx, location, method, input, options)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(interface{})
@@ -245,8 +245,8 @@ func (_m *Plugin) QueryContract(ctx context.Context, location *fftypes.JSONAny,
 	}
 
 	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, *fftypes.JSONAny, *core.FFIMethod, map[string]interface{}) error); ok {
-		r1 = rf(ctx, location, method, input)
+	if rf, ok := ret.Get(1).(func(context.Context, *fftypes.JSONAny, *core.FFIMethod, map[string]interface{}, map[string]interface{}) error); ok {
+		r1 = rf(ctx, location, method, input, options)
 	} else {
 		r1 = ret.Error(1)
 	}
diff --git a/pkg/blockchain/plugin.go b/pkg/blockchain/plugin.go
index 9b6198dc4..f180e987a 100644
--- a/pkg/blockchain/plugin.go
+++ b/pkg/blockchain/plugin.go
@@ -67,10 +67,10 @@ type Plugin interface {
 	SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.NetworkActionType) error
 
 	// InvokeContract submits a new transaction to be executed by custom on-chain logic
-	InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error
+	InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) error
 
 	// QueryContract executes a method via custom on-chain logic and returns the result
-	QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) (interface{}, error)
+	QueryContract(ctx context.Context, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}, options map[string]interface{}) (interface{}, error)
 
 	// AddContractListener adds a new subscription to a user-specified contract and event
 	AddContractListener(ctx context.Context, subscription *core.ContractListenerInput) error
diff --git a/pkg/core/contracts.go b/pkg/core/contracts.go
index bfb6c69b3..eb051d514 100644
--- a/pkg/core/contracts.go
+++ b/pkg/core/contracts.go
@@ -39,6 +39,7 @@ type ContractCallRequest struct {
 	Method     *FFIMethod             `ffstruct:"ContractCallRequest" json:"method,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
 	MethodPath string                 `ffstruct:"ContractCallRequest" json:"methodPath,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
 	Input      map[string]interface{} `ffstruct:"ContractCallRequest" json:"input"`
+	Options    map[string]interface{} `ffstruct:"ContractCallRequest" json:"options"`
 }
 
 type ContractURLs struct {


Test patch:
diff --git a/docs/config_docs_generate_test.go b/docs/config_docs_generate_test.go
index 6345698b3..fe2b7d880 100644
--- a/docs/config_docs_generate_test.go
+++ b/docs/config_docs_generate_test.go
@@ -14,6 +14,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+//go:build reference
+// +build reference
+
 package config
 
 import (
diff --git a/internal/blockchain/ethereum/ethereum_test.go b/internal/blockchain/ethereum/ethereum_test.go
index 264308deb..9ef512015 100644
--- a/internal/blockchain/ethereum/ethereum_test.go
+++ b/internal/blockchain/ethereum/ethereum_test.go
@@ -1854,6 +1854,9 @@ func TestInvokeContractOK(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{
+		"customOption": "customValue",
+	}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
@@ -1865,12 +1868,82 @@ func TestInvokeContractOK(t *testing.T) {
 			assert.Equal(t, "SendTransaction", headers["type"])
 			assert.Equal(t, float64(1), params[0])
 			assert.Equal(t, float64(2), params[1])
+			assert.Equal(t, body["customOption"].(string), "customValue")
 			return httpmock.NewJsonResponderOrPanic(200, "")(req)
 		})
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.NoError(t, err)
 }
 
+func TestInvokeContractInvalidOption(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+	signingKey := ethHexFormatB32(fftypes.NewRandB32())
+	location := &Location{
+		Address: "0x12345",
+	}
+	method := testFFIMethod()
+	params := map[string]interface{}{
+		"x": float64(1),
+		"y": float64(2),
+	}
+	options := map[string]interface{}{
+		"params": "shouldn't be allowed",
+	}
+	locationBytes, err := json.Marshal(location)
+	assert.NoError(t, err)
+	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
+		func(req *http.Request) (*http.Response, error) {
+			var body map[string]interface{}
+			json.NewDecoder(req.Body).Decode(&body)
+			params := body["params"].([]interface{})
+			headers := body["headers"].(map[string]interface{})
+			assert.Equal(t, "SendTransaction", headers["type"])
+			assert.Equal(t, float64(1), params[0])
+			assert.Equal(t, float64(2), params[1])
+			return httpmock.NewJsonResponderOrPanic(200, "")(req)
+		})
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
+	assert.Regexp(t, "FF10398", err)
+}
+
+func TestInvokeContractInvalidInput(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+	signingKey := ethHexFormatB32(fftypes.NewRandB32())
+	location := &Location{
+		Address: "0x12345",
+	}
+	method := testFFIMethod()
+	params := map[string]interface{}{
+		"x": map[bool]bool{true: false},
+		"y": float64(2),
+	}
+	options := map[string]interface{}{
+		"customOption": "customValue",
+	}
+	locationBytes, err := json.Marshal(location)
+	assert.NoError(t, err)
+	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
+		func(req *http.Request) (*http.Response, error) {
+			var body map[string]interface{}
+			json.NewDecoder(req.Body).Decode(&body)
+			params := body["params"].([]interface{})
+			headers := body["headers"].(map[string]interface{})
+			assert.Equal(t, "SendTransaction", headers["type"])
+			assert.Equal(t, float64(1), params[0])
+			assert.Equal(t, float64(2), params[1])
+			assert.Equal(t, body["customOption"].(string), "customValue")
+			return httpmock.NewJsonResponderOrPanic(200, "")(req)
+		})
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
+	assert.Regexp(t, "unsupported type", err)
+}
+
 func TestInvokeContractAddressNotSet(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
@@ -1881,9 +1954,10 @@ func TestInvokeContractAddressNotSet(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "'address' not set", err)
 }
 
@@ -1901,13 +1975,14 @@ func TestInvokeContractEthconnectError(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
 		func(req *http.Request) (*http.Response, error) {
 			return httpmock.NewJsonResponderOrPanic(400, "")(req)
 		})
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF10111", err)
 }
 
@@ -1932,9 +2007,10 @@ func TestInvokeContractPrepareFail(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "invalid json", err)
 }
 
@@ -1948,6 +2024,9 @@ func TestQueryContractOK(t *testing.T) {
 	}
 	method := testFFIMethod()
 	params := map[string]interface{}{}
+	options := map[string]interface{}{
+		"customOption": "customValue",
+	}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
@@ -1956,15 +2035,43 @@ func TestQueryContractOK(t *testing.T) {
 			json.NewDecoder(req.Body).Decode(&body)
 			headers := body["headers"].(map[string]interface{})
 			assert.Equal(t, "Query", headers["type"])
+			assert.Equal(t, body["customOption"].(string), "customValue")
 			return httpmock.NewJsonResponderOrPanic(200, queryOutput{Output: "3"})(req)
 		})
-	result, err := e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	result, err := e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.NoError(t, err)
 	j, err := json.Marshal(result)
 	assert.NoError(t, err)
 	assert.Equal(t, `{"output":"3"}`, string(j))
 }
 
+func TestQueryContractInvalidOption(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+	location := &Location{
+		Address: "0x12345",
+	}
+	method := testFFIMethod()
+	params := map[string]interface{}{}
+	options := map[string]interface{}{
+		"params": "shouldn't be allowed",
+	}
+	locationBytes, err := json.Marshal(location)
+	assert.NoError(t, err)
+	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
+		func(req *http.Request) (*http.Response, error) {
+			var body map[string]interface{}
+			json.NewDecoder(req.Body).Decode(&body)
+			headers := body["headers"].(map[string]interface{})
+			assert.Equal(t, "Query", headers["type"])
+			return httpmock.NewJsonResponderOrPanic(200, queryOutput{Output: "3"})(req)
+		})
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
+	assert.Regexp(t, "FF10398", err)
+}
+
 func TestQueryContractErrorPrepare(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
@@ -1982,9 +2089,10 @@ func TestQueryContractErrorPrepare(t *testing.T) {
 		},
 	}
 	params := map[string]interface{}{}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "invalid json", err)
 }
 
@@ -1997,9 +2105,10 @@ func TestQueryContractAddressNotSet(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "'address' not set", err)
 }
 
@@ -2016,13 +2125,14 @@ func TestQueryContractEthconnectError(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
 		func(req *http.Request) (*http.Response, error) {
 			return httpmock.NewJsonResponderOrPanic(400, queryOutput{})(req)
 		})
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF10111", err)
 }
 
@@ -2039,6 +2149,7 @@ func TestQueryContractUnmarshalResponseError(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
@@ -2049,7 +2160,7 @@ func TestQueryContractUnmarshalResponseError(t *testing.T) {
 			assert.Equal(t, "Query", headers["type"])
 			return httpmock.NewStringResponder(200, "[definitely not JSON}")(req)
 		})
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "invalid character", err)
 }
 
diff --git a/internal/blockchain/fabric/fabric_test.go b/internal/blockchain/fabric/fabric_test.go
index 1ca27875a..e16f94415 100644
--- a/internal/blockchain/fabric/fabric_test.go
+++ b/internal/blockchain/fabric/fabric_test.go
@@ -1589,6 +1589,9 @@ func TestInvokeContractOK(t *testing.T) {
 		"y":           float64(2),
 		"description": "test",
 	}
+	options := map[string]interface{}{
+		"customOption": "customValue",
+	}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/transactions`,
@@ -1601,9 +1604,10 @@ func TestInvokeContractOK(t *testing.T) {
 			assert.Equal(t, "1", body["args"].(map[string]interface{})["x"])
 			assert.Equal(t, "2", body["args"].(map[string]interface{})["y"])
 			assert.Equal(t, "test", body["args"].(map[string]interface{})["description"])
+			assert.Equal(t, "customValue", body["customOption"])
 			return httpmock.NewJsonResponderOrPanic(200, "")(req)
 		})
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.NoError(t, err)
 }
 
@@ -1632,12 +1636,35 @@ func TestInvokeContractBadSchema(t *testing.T) {
 		"y":           float64(2),
 		"description": "test",
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF00127", err)
 }
 
+func TestInvokeContractInvalidOption(t *testing.T) {
+	e, cancel := newTestFabric()
+	defer cancel()
+	signingKey := fftypes.NewRandB32().String()
+	location := &Location{
+		Channel:   "firefly",
+		Chaincode: "simplestorage",
+	}
+	method := testFFIMethod()
+	params := map[string]interface{}{
+		"x": float64(1),
+		"y": float64(2),
+	}
+	options := map[string]interface{}{
+		"func": "foobar",
+	}
+	locationBytes, err := json.Marshal(location)
+	assert.NoError(t, err)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
+	assert.Regexp(t, "FF10398", err)
+}
+
 func TestInvokeContractChaincodeNotSet(t *testing.T) {
 	e, cancel := newTestFabric()
 	defer cancel()
@@ -1648,9 +1675,10 @@ func TestInvokeContractChaincodeNotSet(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF10310", err)
 }
 
@@ -1669,13 +1697,14 @@ func TestInvokeContractFabconnectError(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/transactions`,
 		func(req *http.Request) (*http.Response, error) {
 			return httpmock.NewJsonResponderOrPanic(400, "")(req)
 		})
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF10284", err)
 }
 
@@ -1695,6 +1724,9 @@ func TestQueryContractOK(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{
+		"customOption": "customValue",
+	}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/query`,
@@ -1706,9 +1738,10 @@ func TestQueryContractOK(t *testing.T) {
 			assert.Equal(t, "simplestorage", body["headers"].(map[string]interface{})["chaincode"])
 			assert.Equal(t, "1", body["args"].(map[string]interface{})["x"])
 			assert.Equal(t, "2", body["args"].(map[string]interface{})["y"])
+			assert.Equal(t, "customValue", body["customOption"])
 			return httpmock.NewJsonResponderOrPanic(200, &fabQueryNamedOutput{})(req)
 		})
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.NoError(t, err)
 }
 
@@ -1727,9 +1760,10 @@ func TestQueryContractInputNotJSON(t *testing.T) {
 	params := map[string]interface{}{
 		"bad": map[interface{}]interface{}{true: false},
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF00127", err)
 }
 
@@ -1745,7 +1779,8 @@ func TestQueryContractBadLocation(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
-	_, err := e.QueryContract(context.Background(), fftypes.JSONAnyPtr(`{"validLocation": false}`), method, params)
+	options := map[string]interface{}{}
+	_, err := e.QueryContract(context.Background(), fftypes.JSONAnyPtr(`{"validLocation": false}`), method, params, options)
 	assert.Regexp(t, "FF10310", err)
 }
 
@@ -1763,13 +1798,14 @@ func TestQueryContractFabconnectError(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/query`,
 		func(req *http.Request) (*http.Response, error) {
 			return httpmock.NewJsonResponderOrPanic(400, &fabQueryNamedOutput{})(req)
 		})
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF10284", err)
 }
 
@@ -1787,6 +1823,7 @@ func TestQueryContractUnmarshalResponseError(t *testing.T) {
 		"x": float64(1),
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/query`,
@@ -1799,7 +1836,7 @@ func TestQueryContractUnmarshalResponseError(t *testing.T) {
 			assert.Equal(t, "2", body["args"].(map[string]interface{})["y"])
 			return httpmock.NewStringResponder(200, "[definitely not JSON}")(req)
 		})
-	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	_, err = e.QueryContract(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "invalid character", err)
 }
 
@@ -1843,13 +1880,14 @@ func TestInvokeJSONEncodeParamsError(t *testing.T) {
 		"x": map[bool]interface{}{true: false},
 		"y": float64(2),
 	}
+	options := map[string]interface{}{}
 	locationBytes, err := json.Marshal(location)
 	assert.NoError(t, err)
 	httpmock.RegisterResponder("POST", `http://localhost:12345/transactions`,
 		func(req *http.Request) (*http.Response, error) {
 			return httpmock.NewJsonResponderOrPanic(400, "")(req)
 		})
-	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params)
+	err = e.InvokeContract(context.Background(), nil, signingKey, fftypes.JSONAnyPtrBytes(locationBytes), method, params, options)
 	assert.Regexp(t, "FF00127", err)
 }
 
diff --git a/internal/contracts/manager_test.go b/internal/contracts/manager_test.go
index fb0b3296d..0727d6763 100644
--- a/internal/contracts/manager_test.go
+++ b/internal/contracts/manager_test.go
@@ -1523,7 +1523,7 @@ func TestQueryContract(t *testing.T) {
 	mdi.On("InsertOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
 	})).Return(nil)
-	mbi.On("QueryContract", mock.Anything, req.Location, req.Method, req.Input).Return(struct{}{}, nil)
+	mbi.On("QueryContract", mock.Anything, req.Location, req.Method, req.Input, req.Options).Return(struct{}{}, nil)
 
 	_, err := cm.InvokeContract(context.Background(), "ns1", req, false)
 
diff --git a/internal/contracts/operations_test.go b/internal/contracts/operations_test.go
index 77b141939..568973668 100644
--- a/internal/contracts/operations_test.go
+++ b/internal/contracts/operations_test.go
@@ -54,7 +54,7 @@ func TestPrepareAndRunBlockchainInvoke(t *testing.T) {
 		return loc.String() == req.Location.String()
 	}), mock.MatchedBy(func(method *core.FFIMethod) bool {
 		return method.Name == req.Method.Name
-	}), req.Input).Return(nil)
+	}), req.Input, req.Options).Return(nil)
 
 	po, err := cm.PrepareOperation(context.Background(), op)
 	assert.NoError(t, err)
diff --git a/internal/oapiffi/ffi2swagger_test.go b/internal/oapiffi/ffi2swagger_test.go
index 2847ad7ed..55cc21ce0 100644
--- a/internal/oapiffi/ffi2swagger_test.go
+++ b/internal/oapiffi/ffi2swagger_test.go
@@ -119,25 +119,25 @@ func TestGenerate(t *testing.T) {
 
 	invokeMethod1 := doc.Paths["/invoke/method1"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", invokeMethod1.Type)
-	assert.ElementsMatch(t, []string{"input", "location"}, paramNames(invokeMethod1.Properties))
+	assert.ElementsMatch(t, []string{"input", "location", "options"}, paramNames(invokeMethod1.Properties))
 	assert.Equal(t, "object", invokeMethod1.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{"x", "y", "z"}, paramNames(invokeMethod1.Properties["input"].Value.Properties))
 
 	invokeMethod2 := doc.Paths["/invoke/method2"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", invokeMethod2.Type)
-	assert.ElementsMatch(t, []string{"input", "location"}, paramNames(invokeMethod2.Properties))
+	assert.ElementsMatch(t, []string{"input", "location", "options"}, paramNames(invokeMethod2.Properties))
 	assert.Equal(t, "object", invokeMethod2.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{}, paramNames(invokeMethod2.Properties["input"].Value.Properties))
 
 	queryMethod1 := doc.Paths["/query/method1"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", queryMethod1.Type)
-	assert.ElementsMatch(t, []string{"input", "location"}, paramNames(queryMethod1.Properties))
+	assert.ElementsMatch(t, []string{"input", "location", "options"}, paramNames(queryMethod1.Properties))
 	assert.Equal(t, "object", queryMethod1.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{"x", "y", "z"}, paramNames(queryMethod1.Properties["input"].Value.Properties))
 
 	queryMethod2 := doc.Paths["/query/method2"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", queryMethod2.Type)
-	assert.ElementsMatch(t, []string{"input", "location"}, paramNames(queryMethod2.Properties))
+	assert.ElementsMatch(t, []string{"input", "location", "options"}, paramNames(queryMethod2.Properties))
 	assert.Equal(t, "object", queryMethod2.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{}, paramNames(queryMethod2.Properties["input"].Value.Properties))
 }
@@ -155,25 +155,25 @@ func TestGenerateWithLocation(t *testing.T) {
 
 	invokeMethod1 := doc.Paths["/invoke/method1"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", invokeMethod1.Type)
-	assert.ElementsMatch(t, []string{"input"}, paramNames(invokeMethod1.Properties))
+	assert.ElementsMatch(t, []string{"input", "options"}, paramNames(invokeMethod1.Properties))
 	assert.Equal(t, "object", invokeMethod1.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{"x", "y", "z"}, paramNames(invokeMethod1.Properties["input"].Value.Properties))
 
 	invokeMethod2 := doc.Paths["/invoke/method2"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", invokeMethod2.Type)
-	assert.ElementsMatch(t, []string{"input"}, paramNames(invokeMethod2.Properties))
+	assert.ElementsMatch(t, []string{"input", "options"}, paramNames(invokeMethod2.Properties))
 	assert.Equal(t, "object", invokeMethod2.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{}, paramNames(invokeMethod2.Properties["input"].Value.Properties))
 
 	queryMethod1 := doc.Paths["/query/method1"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", queryMethod1.Type)
-	assert.ElementsMatch(t, []string{"input"}, paramNames(queryMethod1.Properties))
+	assert.ElementsMatch(t, []string{"input", "options"}, paramNames(queryMethod1.Properties))
 	assert.Equal(t, "object", queryMethod1.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{"x", "y", "z"}, paramNames(queryMethod1.Properties["input"].Value.Properties))
 
 	queryMethod2 := doc.Paths["/query/method2"].Post.RequestBody.Value.Content.Get("application/json").Schema.Value
 	assert.Equal(t, "object", queryMethod2.Type)
-	assert.ElementsMatch(t, []string{"input"}, paramNames(queryMethod2.Properties))
+	assert.ElementsMatch(t, []string{"input", "options"}, paramNames(queryMethod2.Properties))
 	assert.Equal(t, "object", queryMethod2.Properties["input"].Value.Type)
 	assert.ElementsMatch(t, []string{}, paramNames(queryMethod2.Properties["input"].Value.Properties))
 }