Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keystone: EVM write capability + forwarder contract #12045

Merged
merged 7 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
archseer marked this conversation as resolved.
Show resolved Hide resolved
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
Loading