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

contracts: submit pre-signed transaction #175

Merged
merged 20 commits into from
Oct 18, 2023
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
15 changes: 10 additions & 5 deletions clients/js/src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,11 +573,13 @@ async function repackRawTx(
);
} catch (e) {
if (e instanceof EnvelopeError) throw e;
if (globalThis?.process?.env?.NODE_ENV !== 'test') {
console.trace(REPACK_ERROR);
}
}
const tx = ethers6.Transaction.from(raw);
if (tx.isSigned() && (!signer || (await signer!.getAddress()) != tx.from!)) {
// encrypted tx cannot be re-signed, allow passthrough when
// submitting a transaction signed by another keypair
return tx.serialized;
}
const q = (v: bigint | null | undefined): string | undefined => {
if (!v) return undefined;
return ethers6.toQuantity(v);
Expand All @@ -592,14 +594,17 @@ async function repackRawTx(
value: q(tx.value),
chainId: Number(tx.chainId),
};
if (!signer) throw new CallError(REPACK_ERROR, null);
if (!parsed.gasLimit) parsed.gasLimit = q(BigInt(DEFAULT_GAS)); // TODO(39)
try {
return signer.signTransaction({
return signer!.signTransaction({
...parsed,
data: await cipher.encryptEncode(data),
});
} catch (e) {
// Many JSON-RPC providers, Ethers included, will not let you directly
// sign transactions, which is necessary to re-encrypt the calldata!
// Throw an error here to prevent calls which should've been encrypted
// from being submitted unencrypted.
throw new CallError(REPACK_ERROR, e);
}
}
Expand Down
20 changes: 20 additions & 0 deletions contracts/contracts/tests/EIP155Tests.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ contract EIP155Tests {
payable(publicAddr).transfer(msg.value);
}

function getChainId() external view returns (uint256) {
return block.chainid;
}

event HasChainId(uint256);

function emitChainId() external {
emit HasChainId(block.chainid);
}

function sign(EIP155Signer.EthTx memory transaction)
external
view
Expand All @@ -24,6 +34,16 @@ contract EIP155Tests {
return EIP155Signer.sign(publicAddr, secretKey, transaction);
}

function signWithSecret(
EIP155Signer.EthTx memory transaction,
address fromPublicAddr,
bytes32 fromSecret
) external view returns (bytes memory) {
transaction.data = abi.encodeWithSelector(this.example.selector);
transaction.chainId = block.chainid;
return EIP155Signer.sign(fromPublicAddr, fromSecret, transaction);
}

event ExampleEvent(bytes32 x);

function example() external {
Expand Down
4 changes: 2 additions & 2 deletions contracts/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const config: HardhatUserConfig = {
chainId: 0x5aff,
accounts: process.env.SAPPHIRE_TESTNET_PRIVATE_KEY
? [process.env.SAPPHIRE_TESTNET_PRIVATE_KEY]
: [],
: TEST_HDWALLET,
},
'sapphire-mainnet': {
url: 'https://sapphire.oasis.io',
Expand Down Expand Up @@ -100,7 +100,7 @@ const config: HardhatUserConfig = {
},
mocha: {
require: ['ts-node/register/files'],
timeout: 20_000,
timeout: 60_000,
},
};

Expand Down
1 change: 1 addition & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"devDependencies": {
"@nomicfoundation/hardhat-chai-matchers": "^1.0.5",
"@nomiclabs/hardhat-ethers": "^2.1.1",
"@oasisprotocol/sapphire-paratime": "workspace:^",
"@oasisprotocol/sapphire-hardhat": "workspace:^",
"@oasisprotocol/client": "^0.1.1-alpha.2",
"@openzeppelin/contracts": "^4.7.3",
Expand Down
140 changes: 120 additions & 20 deletions contracts/test/eip155.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,143 @@
// SPDX-License-Identifier: Apache-2.0

import { expect } from 'chai';
import { ethers } from 'hardhat';
import hre, { ethers } from 'hardhat';
import * as sapphire from '@oasisprotocol/sapphire-paratime';
import { EIP155Tests__factory } from '../typechain-types/factories/contracts/tests';
import { EIP155Tests } from '../typechain-types/contracts/tests/EIP155Tests';
import { HardhatNetworkHDAccountsConfig } from 'hardhat/types';

const EXPECTED_EVENT =
'0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210';

const EXPECTED_ENTROPY_ENCRYPTED = 3.8;

// Shannon entropy
function entropy(str: string) {
return [...new Set(str)]
.map((chr) => {
return str.match(new RegExp(chr, 'g'))!.length;
})
.reduce((sum, frequency) => {
let p = frequency / str.length;
return sum + p * Math.log2(1 / p);
}, 0);
}

function getWallet(index: number) {
const accounts = hre.network.config
.accounts as HardhatNetworkHDAccountsConfig;
if (!accounts.mnemonic) {
return new ethers.Wallet((accounts as unknown as string[])[0]);
}
return ethers.Wallet.fromMnemonic(
accounts.mnemonic,
accounts.path + `/${index}`,
);
}

describe('EIP-155', function () {
async function deploy() {
let testContract: EIP155Tests;
let calldata: string;
before(async () => {
const factory = (await ethers.getContractFactory(
'EIP155Tests',
)) as EIP155Tests__factory;
const testContract = await factory.deploy({
testContract = await factory.deploy({
value: ethers.utils.parseEther('1'),
});
return { testContract };
}
await testContract.deployed();
calldata = testContract.interface.encodeFunctionData('example');
});

it('Has correct block.chainid', async () => {
const provider = testContract.provider;
const expectedChainId = (await provider.getNetwork()).chainId;

// Emitted via transaction
const tx = await testContract.emitChainId();
const receipt = await tx.wait();
expect(receipt.events![0].args![0]).eq(expectedChainId);

it('Signed transactions can be submitted', async function () {
const { testContract } = await deploy();
const txobj = {
nonce: 0,
gasPrice: await testContract.provider.getGasPrice(),
// Returned from view call
const onchainChainId = await testContract.getChainId();
expect(onchainChainId).eq(expectedChainId);
});

it('Wrapper encrypts self-signed transaction calldata', async function () {
const tx = await testContract.example();
expect(entropy(tx.data)).gte(EXPECTED_ENTROPY_ENCRYPTED);
expect(tx.data).not.eq(calldata);
expect(tx.data.length).eq(218);
});

/// Verify that contracts can sign transactions for submission with an unwrapped provider
it('Other-Signed transaction submission via un-wrapped provider', async function () {
const provider = testContract.provider;
const signedTx = await testContract.sign({
nonce: await provider.getTransactionCount(
await testContract.publicAddr(),
),
gasPrice: await provider.getGasPrice(),
gasLimit: 250000,
to: testContract.address,
value: 0,
data: '0x',
chainId: 0,
};
const signedTx = await testContract.sign(txobj);
});

// Submit signed transaction via plain JSON-RPC provider (avoiding saphire.wrap)
const plainProvider = new ethers.providers.StaticJsonRpcProvider(
ethers.provider.connection,
);
let plainResp = await plainProvider.sendTransaction(signedTx);
// Submit signed transaction via plain JSON-RPC provider (avoiding sapphire.wrap)
let plainResp = await provider.sendTransaction(signedTx);
let receipt = await testContract.provider.waitForTransaction(
plainResp.hash,
);
expect(receipt.logs[0].data).equal(
'0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
);
expect(plainResp.data).eq(calldata);
expect(receipt.logs[0].data).equal(EXPECTED_EVENT);
});

/// Verify that contracts can sign transactions for submission with a wrapped provider
/// Transactions signed by other accounts should pass-through the wrapped provider
it('Other-Signed transaction submission via wrapped provider', async function () {
const provider = testContract.provider;
const signedTx = await testContract.sign({
nonce: await provider.getTransactionCount(
await testContract.publicAddr(),
),
gasPrice: await provider.getGasPrice(),
gasLimit: 250000,
to: testContract.address,
value: 0,
data: '0x',
chainId: 0,
});

let plainResp = await provider.sendTransaction(signedTx);
let receipt = await provider.waitForTransaction(plainResp.hash);
expect(plainResp.data).eq(calldata);
expect(receipt.logs[0].data).equal(EXPECTED_EVENT);
});

/// Verify that the wrapped wallet will encrypt a manually signed transaction
it('Self-Signed transaction submission via wrapped wallet', async function () {
const provider = testContract.provider;
const wallet = sapphire.wrap(getWallet(0).connect(provider));

const signedTx = await wallet.signTransaction({
gasLimit: 250000,
to: testContract.address,
value: 0,
data: calldata,
chainId: (await provider.getNetwork()).chainId,
gasPrice: await provider.getGasPrice(),
nonce: await provider.getTransactionCount(wallet.address),
});

// Calldata should be encrypted when we wrap the wallet provider
let x = await provider.sendTransaction(signedTx);
expect(entropy(x.data)).gte(EXPECTED_ENTROPY_ENCRYPTED);
expect(x.data).not.eq(calldata);

let r = await provider.waitForTransaction(x.hash);
expect(r.logs[0].data).equal(EXPECTED_EVENT);
});
});
4 changes: 4 additions & 0 deletions examples/onchain-signer/contracts/CommentBox.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ pragma solidity ^0.8.19;
contract CommentBox {
string[] public comments;

function commentCount() external view returns (uint256) {
return comments.length;
}

function comment(string memory commentText) external {
comments.push(commentText);
}
Expand Down
78 changes: 37 additions & 41 deletions examples/onchain-signer/test/CommentBox.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { expect } from 'chai';
import { config, ethers } from 'hardhat';
import hre, { config, ethers } from 'hardhat';
import { CommentBox, Gasless } from '../typechain-types';
import { HDAccountsUserConfig } from 'hardhat/types';

describe('CommentBox', function () {
async function deployCommentBoxWithProxy() {
const CommentBox = await ethers.getContractFactory('CommentBox');
const commentBox = await CommentBox.deploy();
let commentBox: CommentBox;
let gasless: Gasless;

const Gasless = await ethers.getContractFactory('Gasless');
const gasless = await Gasless.deploy();
before(async () => {
const CommentBoxFactory = await ethers.getContractFactory('CommentBox');
commentBox = await CommentBoxFactory.deploy();
await commentBox.deployed();

const GaslessFactory = await ethers.getContractFactory('Gasless');
gasless = await GaslessFactory.deploy();
await gasless.deployed();

// Derive the private key of the 1st (counting from 0) builtin hardhat test account.
const accounts = config.networks.hardhat.accounts;
const accounts = config.networks.hardhat
.accounts as unknown as HDAccountsUserConfig;
const wallet1 = ethers.Wallet.fromMnemonic(
accounts.mnemonic,
accounts.path + `/1`,
);
const privateKey1 = wallet1.privateKey;

// Use it as the relayer private key.
await expect(
Expand All @@ -27,41 +34,30 @@ describe('CommentBox', function () {
nonce: ethers.provider.getTransactionCount(wallet1.address),
}),
).not.to.be.reverted;
});

return { commentBox, gasless };
}

describe('Deployment', function () {
it('Should comment', async function () {
const { commentBox, _gasless } = await deployCommentBoxWithProxy();

await expect(commentBox.comment('Hello, world!')).not.to.be.reverted;
expect(commentBox.comments()).length == 1;
});

it('Should comment gasless', async function () {
// This test requires RNG and runs on the Sapphire network only.
// You can set up sapphire-dev image and run the test like this:
// docker run -it -p8545:8545 -p8546:8546 ghcr.io/oasisprotocol/sapphire-dev -to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// npx hardhat test --grep proxy --network sapphire-localnet
if ((await ethers.provider.getNetwork()).chainId == 1337) {
this.skip();
}
const { commentBox, gasless } = await deployCommentBoxWithProxy();

const innercall = commentBox.interface.encodeFunctionData('comment', [
'Hello, free world!',
]);
const tx = await gasless.makeProxyTx(commentBox.address, innercall);
it('Should comment', async function () {
const prevCommentCount = await commentBox.commentCount();

// TODO: https://github.com/oasisprotocol/sapphire-paratime/issues/179
const plainProvider = new ethers.providers.JsonRpcProvider(
ethers.provider.connection,
);
const plainResp = await plainProvider.sendTransaction(tx);
const tx = await commentBox.comment('Hello, world!');
await tx.wait();
expect(await commentBox.commentCount()).eq(prevCommentCount.add(1));
});

const receipt = await ethers.provider.waitForTransaction(plainResp.hash);
if (!receipt || receipt.status != 1) throw new Error('tx failed');
});
it('Should comment gasless', async function () {
// You can set up sapphire-dev image and run the test like this:
// docker run -it -p8545:8545 -p8546:8546 ghcr.io/oasisprotocol/sapphire-dev -to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// npx hardhat test --grep proxy --network sapphire-localnet
if ((await ethers.provider.getNetwork()).chainId == 1337) {
this.skip();
}
const innercall = commentBox.interface.encodeFunctionData('comment', [
'Hello, free world!',
]);
const tx = await gasless.makeProxyTx(commentBox.address, innercall);

const plainResp = await gasless.provider.sendTransaction(tx);
const receipt = await ethers.provider.waitForTransaction(plainResp.hash);
if (!receipt || receipt.status != 1) throw new Error('tx failed');
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading