Skip to content

Commit

Permalink
Keystone: EVM write capability + forwarder contract (smartcontractkit…
Browse files Browse the repository at this point in the history
…#12045)

* Add keystone forwarder contract + EVM write capability

* Dust off nix flake, update dependencies

* KeystoneForwarder: Test basic functionality

* Use chain-selectors to name the capability

* Mark utils as internal for inlining?

* Add missing gethwrappers

* Fix go.md

---------

Co-authored-by: Bolek Kulbabinski <[email protected]>
  • Loading branch information
archseer and bolekk authored Feb 16, 2024
1 parent 7685116 commit cce6c80
Show file tree
Hide file tree
Showing 38 changed files with 1,311 additions and 21 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/solidity-foundry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
strategy:
fail-fast: false
matrix:
product: [vrf, automation, llo-feeds, l2ep, functions, shared]
product: [vrf, automation, llo-feeds, l2ep, functions, keystone, shared]
needs: [changes]
name: Foundry Tests ${{ matrix.product }}
# See https://github.com/foundry-rs/foundry/issues/3827
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/solidity-hardhat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
with:
filters: |
src:
- 'contracts/src/!(v0.8/(llo-feeds|ccip)/**)/**/*'
- 'contracts/src/!(v0.8/(llo-feeds|keystone|ccip)/**)/**/*'
- 'contracts/test/**/*'
- 'contracts/package.json'
- 'contracts/pnpm-lock.yaml'
Expand Down
3 changes: 3 additions & 0 deletions common/txmgr/types/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ type TxMeta[ADDR types.Hashable, TX_HASH types.Hashable] struct {
ForceFulfilled *bool `json:"ForceFulfilled,omitempty"`
ForceFulfillmentAttempt *uint64 `json:"ForceFulfillmentAttempt,omitempty"`

// Used for Keystone Workflows
WorkflowExecutionID *string `json:"WorkflowExecutionID,omitempty"`

// Used only for forwarded txs, tracks the original destination address.
// When this is set, it indicates tx is forwarded through To address.
FwdrDestAddress *ADDR `json:"ForwarderDestAddress,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion contracts/GNUmakefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ALL_FOUNDRY_PRODUCTS contains a list of all products that have a foundry
# profile defined and use the Foundry snapshots.
ALL_FOUNDRY_PRODUCTS = l2ep llo-feeds functions shared
ALL_FOUNDRY_PRODUCTS = l2ep llo-feeds functions keystone shared

# To make a snapshot for a specific product, either set the `FOUNDRY_PROFILE` env var
# or call the target with `FOUNDRY_PROFILE=product`
Expand Down
6 changes: 6 additions & 0 deletions contracts/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ test = 'src/v0.8/llo-feeds/test'
solc_version = '0.8.19'
# We cannot turn on deny_warnings = true as that will hide any CI failure

[profile.keystone]
solc_version = '0.8.19'
src = 'src/v0.8/keystone'
test = 'src/v0.8/keystone/test'
optimizer_runs = 10_000

[profile.shared]
optimizer_runs = 1000000
src = 'src/v0.8/shared'
Expand Down
2 changes: 2 additions & 0 deletions contracts/gas-snapshots/keystone.gas-snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
KeystoneForwarderTest:test_abi_partial_decoding_works() (gas: 2068)
KeystoneForwarderTest:test_it_works() (gas: 993848)
2 changes: 1 addition & 1 deletion contracts/scripts/native_solc_compile_all
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ python3 -m pip install --require-hashes -r $SCRIPTPATH/requirements.txt
# 6 and 7 are legacy contracts, for each other product we have a native_solc_compile_all_$product script
# These scripts can be run individually, or all together with this script.
# To add new CL products, simply write a native_solc_compile_all_$product script and add it to the list below.
for product in 6 7 automation events_mock feeds functions llo-feeds logpoller operatorforwarder shared transmission vrf
for product in 6 7 automation events_mock feeds functions keystone llo-feeds logpoller operatorforwarder shared transmission vrf
do
$SCRIPTPATH/native_solc_compile_all_$product
done
31 changes: 31 additions & 0 deletions contracts/scripts/native_solc_compile_all_keystone
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash

set -e

echo " ┌──────────────────────────────────────────────┐"
echo " │ Compiling Keystone contracts... │"
echo " └──────────────────────────────────────────────┘"

SOLC_VERSION="0.8.19"
OPTIMIZE_RUNS=1000000


SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
python3 -m pip install --require-hashes -r "$SCRIPTPATH"/requirements.txt
solc-select install $SOLC_VERSION
solc-select use $SOLC_VERSION
export SOLC_VERSION=$SOLC_VERSION

ROOT="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; cd ../../ && pwd -P )"

compileContract () {
local contract
contract=$(basename "$1" ".sol")

solc --overwrite --optimize --optimize-runs $OPTIMIZE_RUNS --metadata-hash none \
-o "$ROOT"/contracts/solc/v$SOLC_VERSION/"$contract" \
--abi --bin --allow-paths "$ROOT"/contracts/src/v0.8\
"$ROOT"/contracts/src/v0.8/"$1"
}

compileContract keystone/KeystoneForwarder.sol
80 changes: 80 additions & 0 deletions contracts/src/v0.8/keystone/KeystoneForwarder.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IForwarder} from "./interfaces/IForwarder.sol";
import {ConfirmedOwner} from "../shared/access/ConfirmedOwner.sol";
import {TypeAndVersionInterface} from "../interfaces/TypeAndVersionInterface.sol";
import {Utils} from "./libraries/Utils.sol";

// solhint-disable custom-errors, no-unused-vars
contract KeystoneForwarder is IForwarder, ConfirmedOwner, TypeAndVersionInterface {
error ReentrantCall();

struct HotVars {
bool reentrancyGuard; // guard against reentrancy
}

HotVars internal s_hotVars; // Mixture of config and state, commonly accessed

mapping(bytes32 => address) internal s_reports;

constructor() ConfirmedOwner(msg.sender) {}

// send a report to targetAddress
function report(
address targetAddress,
bytes calldata data,
bytes[] calldata signatures
) external nonReentrant returns (bool) {
require(data.length > 4 + 64, "invalid data length");

// data is an encoded call with the selector prefixed: (bytes4 selector, bytes report, ...)
// we are able to partially decode just the first param, since we don't know the rest
bytes memory rawReport = abi.decode(data[4:], (bytes));

// TODO: we probably need some type of f value config?

bytes32 hash = keccak256(rawReport);

// validate signatures
for (uint256 i = 0; i < signatures.length; i++) {
// TODO: is libocr-style multiple bytes32 arrays more optimal?
(bytes32 r, bytes32 s, uint8 v) = Utils._splitSignature(signatures[i]);
address signer = ecrecover(hash, v, r, s);
// TODO: we need to store oracle cluster similar to aggregator then, to validate valid signer list
}

(bytes32 workflowId, bytes32 workflowExecutionId) = Utils._splitReport(rawReport);

// report was already processed
if (s_reports[workflowExecutionId] != address(0)) {
return false;
}

// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory result) = targetAddress.call(data);

s_reports[workflowExecutionId] = msg.sender;
return true;
}

// get transmitter of a given report or 0x0 if it wasn't transmitted yet
function getTransmitter(bytes32 workflowExecutionId) external view returns (address) {
return s_reports[workflowExecutionId];
}

/// @inheritdoc TypeAndVersionInterface
function typeAndVersion() external pure override returns (string memory) {
return "KeystoneForwarder 1.0.0";
}

/**
* @dev replicates Open Zeppelin's ReentrancyGuard but optimized to fit our storage
*/
modifier nonReentrant() {
if (s_hotVars.reentrancyGuard) revert ReentrantCall();
s_hotVars.reentrancyGuard = true;
_;
s_hotVars.reentrancyGuard = false;
}
}
5 changes: 5 additions & 0 deletions contracts/src/v0.8/keystone/interfaces/IForwarder.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

/// @title IForwarder - forwards keystone reports to a target
interface IForwarder {}
42 changes: 42 additions & 0 deletions contracts/src/v0.8/keystone/libraries/Utils.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

// solhint-disable custom-errors
library Utils {
// solhint-disable avoid-low-level-calls, chainlink-solidity/explicit-returns
function _splitSignature(bytes memory sig) internal pure returns (bytes32 r, bytes32 s, uint8 v) {
require(sig.length == 65, "invalid signature length");

assembly {
/*
First 32 bytes stores the length of the signature
add(sig, 32) = pointer of sig + 32
effectively, skips first 32 bytes of signature
mload(p) loads next 32 bytes starting at the memory address p into memory
*/

// first 32 bytes, after the length prefix
r := mload(add(sig, 32))
// second 32 bytes
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes)
v := byte(0, mload(add(sig, 96)))
}

// implicitly return (r, s, v)
}

// solhint-disable avoid-low-level-calls, chainlink-solidity/explicit-returns
function _splitReport(
bytes memory rawReport
) internal pure returns (bytes32 workflowId, bytes32 workflowExecutionId) {
require(rawReport.length > 64, "invalid report length");
assembly {
// skip first 32 bytes, contains length of the report
workflowId := mload(add(rawReport, 32))
workflowExecutionId := mload(add(rawReport, 64))
}
}
}
66 changes: 66 additions & 0 deletions contracts/src/v0.8/keystone/test/KeystoneForwarder.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";

import "../KeystoneForwarder.sol";
import {Utils} from "../libraries/Utils.sol";

contract Receiver {
event MessageReceived(bytes32 indexed workflowId, bytes32 indexed workflowExecutionId, bytes[] mercuryReports);

constructor() {}

function foo(bytes calldata rawReport) external {
// decode metadata
(bytes32 workflowId, bytes32 workflowExecutionId) = Utils._splitReport(rawReport);
// parse actual report
bytes[] memory mercuryReports = abi.decode(rawReport[64:], (bytes[]));
emit MessageReceived(workflowId, workflowExecutionId, mercuryReports);
}
}

contract KeystoneForwarderTest is Test {
function setUp() public virtual {}

function test_abi_partial_decoding_works() public {
bytes memory report = hex"0102";
uint256 amount = 1;
bytes memory payload = abi.encode(report, amount);
bytes memory decodedReport = abi.decode(payload, (bytes));
assertEq(decodedReport, report, "not equal");
}

function test_it_works() public {
KeystoneForwarder forwarder = new KeystoneForwarder();
Receiver receiver = new Receiver();

// taken from https://github.com/smartcontractkit/chainlink/blob/2390ec7f3c56de783ef4e15477e99729f188c524/core/services/relay/evm/cap_encoder_test.go#L42-L55
bytes
memory report = hex"6d795f69640000000000000000000000000000000000000000000000000000006d795f657865637574696f6e5f696400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000301020300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004aabbccdd00000000000000000000000000000000000000000000000000000000";
bytes memory data = abi.encodeWithSignature("foo(bytes)", report);
bytes[] memory signatures = new bytes[](0);

vm.expectCall(address(receiver), data);
vm.recordLogs();

bool delivered1 = forwarder.report(address(receiver), data, signatures);
assertTrue(delivered1, "report not delivered");

Vm.Log[] memory entries = vm.getRecordedLogs();
assertEq(entries[0].emitter, address(receiver));
// validate workflow id and workflow execution id
bytes32 workflowId = hex"6d795f6964000000000000000000000000000000000000000000000000000000";
bytes32 executionId = hex"6d795f657865637574696f6e5f69640000000000000000000000000000000000";
assertEq(entries[0].topics[1], workflowId);
assertEq(entries[0].topics[2], executionId);
bytes[] memory mercuryReports = abi.decode(entries[0].data, (bytes[]));
assertEq(mercuryReports.length, 2);
assertEq(mercuryReports[0], hex"010203");
assertEq(mercuryReports[1], hex"aabbccdd");

// doesn't deliver the same report more than once
bool delivered2 = forwarder.report(address(receiver), data, signatures);
assertFalse(delivered2, "report redelivered");
}
}
Loading

0 comments on commit cce6c80

Please sign in to comment.