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

Add encryptCallData() to gasless chapter and example #477

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
37 changes: 24 additions & 13 deletions docs/gasless.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ recipient contract decodes the meta-transaction, verifies both signatures and
executes the original transaction.

Oasis Sapphire supports two transaction relaying methods: The **on-chain
signer** exposes the Oasis-specific contract state encryption functionality
while the **gas station network** method is a standardized approach known in
other blockchains as well.
signer** is trustless and utilizes the Oasis-specific contract state encryption
while the **gas station network** service is known from other blockchains as
well.

:::caution

Expand All @@ -34,11 +34,15 @@ features such as the browser support are not fully implemented yet.

## On-Chain Signer

The on-chain signer is a smart contract which receives the user's transaction,
checks whether the transaction is valid, wraps it into a meta-transaction
(which includes paying for the transaction fee) and returns it back to the user
in [EIP-155] format. These steps are executed as a confidential call. Finally,
the user submits the generated transaction to the network.
The on-chain signer is a smart contract which:
1. receives the user's transaction,
2. checks whether the transaction is valid,
3. wraps it into a meta-transaction (which includes paying for the transaction
fee) and
4. returns it back to the user in the [EIP-155] format.

The steps above are executed as a confidential read-only call. Finally, the user
then submits the obtained transaction to the network.

![Diagram of the On-Chain Signing](images/gasless-on-chain-signer.svg)

Expand Down Expand Up @@ -77,7 +81,8 @@ private key containing enough balance to cover transaction fees should be
provided in the constructor.

```solidity
import {EIP155Signer} from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol";
import { encryptCallData } from "@oasisprotocol/sapphire-contracts/contracts/CalldataEncryption.sol";
import { EIP155Signer } from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol";

struct EthereumKeypair {
address addr;
Expand Down Expand Up @@ -121,7 +126,7 @@ contract Gasless {
gasLimit: 250000,
to: address(this),
value: 0,
data: abi.encodeCall(this.proxy, data),
data: encryptCallData(abi.encodeCall(this.proxy, data)),
chainId: block.chainid
})
);
Expand Down Expand Up @@ -214,9 +219,15 @@ you must consider the following:

#### Confidentiality

Both the inner- and the meta-transaction are stored on-chain unencrypted. Use
`Sapphire.encrypt()` and `Sapphire.decrypt()` call on the inner-transaction with
an encryption key generated and stored inside a confidential contract state.
The [`encryptCallData()`] helper above will generate an ephemeral key and encrypt
the transaction's calldata. Omit this call to generate a plain transaction. You
can also explicitly encrypt specific function arguments of the inner-transaction
by calling [`Sapphire.encrypt()`] using a private key stored somewhere in your
smart contract and then [`Sapphire.decrypt()`] when executing the transaction.

[`encryptCallData()`]: https://api.docs.oasis.io/sol/sapphire-contracts/contracts/CalldataEncryption.sol/function.encryptCallData.html#encryptcalldatabytes
[`Sapphire.encrypt()`]: https://api.docs.oasis.io/sol/sapphire-contracts/contracts/Sapphire.sol/library.Sapphire.html#encrypt-1
[`Sapphire.decrypt()`]: https://api.docs.oasis.io/sol/sapphire-contracts/contracts/Sapphire.sol/library.Sapphire.html#decrypt-1

#### Gas Cost and Gas Limit

Expand Down
24 changes: 22 additions & 2 deletions examples/onchain-signer/contracts/Gasless.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
pragma solidity ^0.8.20;

import {encryptCallData} from "@oasisprotocol/sapphire-contracts/contracts/CalldataEncryption.sol";
import {EIP155Signer} from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol";

struct EthereumKeypair {
Expand All @@ -25,8 +26,27 @@ contract Gasless {
bytes memory innercall
) external view returns (bytes memory output) {
bytes memory data = abi.encode(innercallAddr, innercall);
return
EIP155Signer.sign(
kp.addr,
kp.secret,
EIP155Signer.EthTx({
nonce: kp.nonce,
gasPrice: 100_000_000_000,
gasLimit: 250000,
to: address(this),
value: 0,
data: encryptCallData(abi.encodeCall(this.proxy, data)),
chainId: block.chainid
})
);
}

// Call will invoke proxy().
function makeProxyTxPlain(
address innercallAddr,
bytes memory innercall
) external view returns (bytes memory output) {
bytes memory data = abi.encode(innercallAddr, innercall);
return
EIP155Signer.sign(
kp.addr,
Expand Down
12 changes: 11 additions & 1 deletion examples/onchain-signer/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ const config: HardhatUserConfig = {
'sapphire-testnet': { ...sapphireTestnet, accounts },
'sapphire-localnet': { ...sapphireLocalnet, accounts },
},
solidity: '0.8.20',
solidity: {
version: '0.8.20',
settings: {
// XXX: Needs to match https://github.com/oasisprotocol/sapphire-paratime/blob/main/contracts/hardhat.config.ts
Copy link
Member

@ptrus ptrus Dec 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on some of my quick testing I believe it is not about matching, but actually things fail without this optimization enabled.

For example, removing this optimization in contracts/hardhat.config.ts makes the tests in contracts/ fail as well.

Do we know why this happens? Should someone investigate this further?

optimizer: {
enabled: true,
runs: (1 << 32) - 1,
},
viaIR: true,
},
},
};

export default config;
76 changes: 41 additions & 35 deletions examples/onchain-signer/test/CommentBox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect } from 'chai';
import { Context } from 'mocha';
import { ethers } from 'hardhat';
import { parseEther, Wallet } from 'ethers';
import { CommentBox, Gasless } from '../typechain-types';
Expand Down Expand Up @@ -33,55 +34,60 @@ describe('CommentBox', function () {
console.log(' . gasless pubkey', wallet.address);
});

it('Should comment', async function () {
this.timeout(10000);
async function commentGasless(comment: string, plain: boolean) {
const provider = ethers.provider;

const innercall = commentBox.interface.encodeFunctionData('comment', [
comment,
]);

const prevCommentCount = await commentBox.commentCount();
let tx: string;
if (plain) {
tx = await gasless.makeProxyTxPlain(
await commentBox.getAddress(),
innercall,
);
} else {
tx = await gasless.makeProxyTx(await commentBox.getAddress(), innercall);
}

const tx = await commentBox.comment('Hello, world!');
await tx.wait();
// TODO: https://github.com/oasisprotocol/sapphire-paratime/issues/179
const response = await provider.broadcastTransaction(tx);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calldata encryption could be checked here (requires sapphire-paratime import)

Suggested change
const response = await provider.broadcastTransaction(tx);
const response = await provider.broadcastTransaction(tx);
expect(isCalldataEnveloped(response.data)).eq(!plain);

also is the TODO still open?
I used await wrapEthereumProvider(provider).broadcastTransaction(tx) and didn't got an error.

await response.wait();

// Sapphire Mainnet/Testnet: Wait a few moments for nodes to catch up.
const chainId = (await ethers.provider.getNetwork()).chainId;
if (chainId == BigInt(23294) || chainId == BigInt(23295)) {
await new Promise((r) => setTimeout(r, 6_000));
}
const receipt = await provider.getTransactionReceipt(response.hash);
if (!receipt || receipt.status != 1) throw new Error('tx failed');

expect(await commentBox.commentCount()).eq(prevCommentCount + BigInt(1));
});
}

it('Should comment', async function () {
const prevCommentCount = await commentBox.commentCount();

it('Should comment gasless', async function () {
this.timeout(10000);
const tx = await commentBox.comment('Hello, world!');
await tx.wait();

const provider = ethers.provider;
expect(await commentBox.commentCount()).eq(prevCommentCount + BigInt(1));
});

// You can set up sapphire-localnet image and run the test like this:
// docker run -it -p8545:8545 -p8546:8546 ghcr.io/oasisprotocol/sapphire-localnet -to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// npx hardhat test --grep proxy --network sapphire-localnet
const chainId = (await provider.getNetwork()).chainId;
if (chainId == BigInt(1337)) {
it('Should comment gasless (encrypted)', async function () {
// Set up sapphire-localnet image to run this test:
// docker run -it -p8544-8548:8544-8548 ghcr.io/oasisprotocol/sapphire-localnet
if ((await ethers.provider.getNetwork()).chainId == BigInt(1337)) {
this.skip();
}

const innercall = commentBox.interface.encodeFunctionData('comment', [
'Hello, free world!',
]);
await commentGasless('Hello, c10l world', false);
});

// Sapphire Mainnet/Testnet: Wait a few moments for nodes to catch up.
if (chainId == BigInt(23294) || chainId == BigInt(23295)) {
await new Promise((r) => setTimeout(r, 6_000));
it('Should comment gasless (plain)', async function () {
// Set up sapphire-localnet image to run this test:
// docker run -it -p8544-8548:8544-8548 ghcr.io/oasisprotocol/sapphire-localnet
if ((await ethers.provider.getNetwork()).chainId == BigInt(1337)) {
this.skip();
}

const tx = await gasless.makeProxyTx(
await commentBox.getAddress(),
innercall,
);

// TODO: https://github.com/oasisprotocol/sapphire-paratime/issues/179
const response = await provider.broadcastTransaction(tx);
await response.wait();

const receipt = await provider.getTransactionReceipt(response.hash);
if (!receipt || receipt.status != 1) throw new Error('tx failed');
await commentGasless('Hello, plain world', true);
});
});
Loading