diff --git a/contracts/clients/src/BundleCompressor.ts b/contracts/clients/src/BundleCompressor.ts new file mode 100644 index 00000000..fb4e006c --- /dev/null +++ b/contracts/clients/src/BundleCompressor.ts @@ -0,0 +1,62 @@ +import { ethers } from "ethers"; +import { encodeVLQ, hexJoin } from "./encodeUtils"; +import IOperationCompressor from "./IOperationCompressor"; +import { Bundle, Operation, PublicKey } from "./signer"; +import Range from "./helpers/Range"; + +export default class BundleCompressor { + compressors: [number, IOperationCompressor][] = []; + + addCompressor(expanderIndex: number, compressor: IOperationCompressor) { + this.compressors.push([expanderIndex, compressor]); + } + + async compressOperation( + blsPublicKey: PublicKey, + operation: Operation, + ): Promise { + let expanderIndexAndData: [number, string] | undefined; + + for (const [expanderIndex, compressor] of this.compressors) { + const data = await compressor.compress(blsPublicKey, operation); + + if (data === undefined) { + continue; + } + + expanderIndexAndData = [expanderIndex, data]; + break; + } + + if (expanderIndexAndData === undefined) { + throw new Error("Failed to compress operation"); + } + + const [expanderIndex, data] = expanderIndexAndData; + + return hexJoin([encodeVLQ(expanderIndex), data]); + } + + async compress(bundle: Bundle): Promise { + const len = bundle.operations.length; + + if (bundle.senderPublicKeys.length !== len) { + throw new Error("ops vs keys length mismatch"); + } + + const compressedOperations = await Promise.all( + Range(len).map((i) => + this.compressOperation( + bundle.senderPublicKeys[i], + bundle.operations[i], + ), + ), + ); + + return hexJoin([ + encodeVLQ(bundle.operations.length), + ...compressedOperations, + ethers.utils.defaultAbiCoder.encode(["uint256[2]"], [bundle.signature]), + ]); + } +} diff --git a/contracts/clients/src/FallbackCompressor.ts b/contracts/clients/src/FallbackCompressor.ts new file mode 100644 index 00000000..09133569 --- /dev/null +++ b/contracts/clients/src/FallbackCompressor.ts @@ -0,0 +1,209 @@ +/* eslint-disable camelcase */ + +import { ethers, Signer } from "ethers"; +import { + AddressRegistry__factory, + BLSPublicKeyRegistry__factory, + FallbackExpander, + FallbackExpander__factory, +} from "../typechain-types"; +import AddressRegistryWrapper from "./AddressRegistryWrapper"; +import BlsPublicKeyRegistryWrapper from "./BlsPublicKeyRegistryWrapper"; +import { + encodeBitStream, + encodePseudoFloat, + encodeRegIndex, + encodeVLQ, + hexJoin, +} from "./encodeUtils"; +import SignerOrProvider from "./helpers/SignerOrProvider"; +import IOperationCompressor from "./IOperationCompressor"; +import SafeSingletonFactory, { + SafeSingletonFactoryViewer, +} from "./SafeSingletonFactory"; +import { Operation, PublicKey } from "./signer"; + +export default class FallbackCompressor implements IOperationCompressor { + private constructor( + public fallbackExpander: FallbackExpander, + public blsPublicKeyRegistry: BlsPublicKeyRegistryWrapper, + public addressRegistry: AddressRegistryWrapper, + ) {} + + static async wrap( + fallbackExpander: FallbackExpander, + ): Promise { + const [blsPublicKeyRegistryAddress, addressRegistryAddress] = + await Promise.all([ + fallbackExpander.blsPublicKeyRegistry(), + fallbackExpander.addressRegistry(), + ]); + + return new FallbackCompressor( + fallbackExpander, + new BlsPublicKeyRegistryWrapper( + BLSPublicKeyRegistry__factory.connect( + blsPublicKeyRegistryAddress, + fallbackExpander.signer, + ), + ), + new AddressRegistryWrapper( + AddressRegistry__factory.connect( + addressRegistryAddress, + fallbackExpander.signer, + ), + ), + ); + } + + static async deployNew(signer: Signer): Promise { + const blsPublicKeyRegistryFactory = new BLSPublicKeyRegistry__factory( + signer, + ); + + const addressRegistryFactory = new AddressRegistry__factory(signer); + + const [blsPublicKeyRegistryContract, addressRegistryContract] = + await Promise.all([ + blsPublicKeyRegistryFactory.deploy(), + addressRegistryFactory.deploy(), + ]); + + const fallbackExpanderFactory = new FallbackExpander__factory(signer); + + const fallbackExpanderContract = await fallbackExpanderFactory.deploy( + blsPublicKeyRegistryContract.address, + addressRegistryContract.address, + ); + + return new FallbackCompressor( + fallbackExpanderContract, + new BlsPublicKeyRegistryWrapper(blsPublicKeyRegistryContract), + new AddressRegistryWrapper(addressRegistryContract), + ); + } + + static async connectOrDeploy( + signerOrFactory: Signer | SafeSingletonFactory, + salt: ethers.utils.BytesLike = ethers.utils.solidityPack(["uint256"], [0]), + ): Promise { + const factory = await SafeSingletonFactory.from(signerOrFactory); + + const [blsPublicKeyRegistry, addressRegistry] = await Promise.all([ + BlsPublicKeyRegistryWrapper.connectOrDeploy(factory, salt), + AddressRegistryWrapper.connectOrDeploy(factory, salt), + ]); + + const fallbackExpanderContract = await factory.connectOrDeploy( + FallbackExpander__factory, + [blsPublicKeyRegistry.registry.address, addressRegistry.registry.address], + salt, + ); + + return new FallbackCompressor( + fallbackExpanderContract, + blsPublicKeyRegistry, + addressRegistry, + ); + } + + static async connectIfDeployed( + signerOrProvider: SignerOrProvider, + salt: ethers.utils.BytesLike = ethers.utils.solidityPack(["uint256"], [0]), + ): Promise { + const factoryViewer = await SafeSingletonFactoryViewer.from( + signerOrProvider, + ); + + const blsPublicKeyRegistry = await factoryViewer.connectIfDeployed( + BLSPublicKeyRegistry__factory, + [], + salt, + ); + + if (!blsPublicKeyRegistry) { + return undefined; + } + + const addressRegistry = await factoryViewer.connectIfDeployed( + AddressRegistry__factory, + [], + salt, + ); + + if (!addressRegistry) { + return undefined; + } + + const fallbackExpander = await factoryViewer.connectIfDeployed( + FallbackExpander__factory, + [blsPublicKeyRegistry.address, addressRegistry.address], + salt, + ); + + if (!fallbackExpander) { + return undefined; + } + + return new FallbackCompressor( + fallbackExpander, + new BlsPublicKeyRegistryWrapper(blsPublicKeyRegistry), + new AddressRegistryWrapper(addressRegistry), + ); + } + + async compress(blsPublicKey: PublicKey, operation: Operation) { + const result: string[] = []; + + const resultIndexForRegUsageBitStream = result.length; + const regUsageBitStream: boolean[] = []; + result.push("0x"); // Placeholder to overwrite + + const blsPublicKeyId = await this.blsPublicKeyRegistry.reverseLookup( + blsPublicKey, + ); + + if (blsPublicKeyId === undefined) { + regUsageBitStream.push(false); + + result.push( + ethers.utils.defaultAbiCoder.encode(["uint256[4]"], [blsPublicKey]), + ); + } else { + regUsageBitStream.push(true); + result.push(encodeRegIndex(blsPublicKeyId)); + } + + result.push(encodeVLQ(operation.nonce)); + result.push(encodePseudoFloat(operation.gas)); + + result.push(encodeVLQ(operation.actions.length)); + + for (const action of operation.actions) { + result.push(encodePseudoFloat(action.ethValue)); + + const addressId = await this.addressRegistry.reverseLookup( + action.contractAddress, + ); + + if (addressId === undefined) { + regUsageBitStream.push(false); + result.push(action.contractAddress); + } else { + regUsageBitStream.push(true); + result.push(encodeRegIndex(addressId)); + } + + const fnHex = ethers.utils.hexlify(action.encodedFunction); + const fnLen = (fnHex.length - 2) / 2; + + result.push(encodeVLQ(fnLen)); + result.push(fnHex); + } + + result[resultIndexForRegUsageBitStream] = + encodeBitStream(regUsageBitStream); + + return hexJoin(result); + } +} diff --git a/contracts/clients/src/IOperationCompressor.ts b/contracts/clients/src/IOperationCompressor.ts new file mode 100644 index 00000000..ff41e2f5 --- /dev/null +++ b/contracts/clients/src/IOperationCompressor.ts @@ -0,0 +1,10 @@ +import { Operation, PublicKey } from "./signer"; + +type IOperationCompressor = { + compress( + blsPublicKey: PublicKey, + operation: Operation, + ): Promise; +}; + +export default IOperationCompressor; diff --git a/contracts/shared/helpers/bundleCompression.ts b/contracts/clients/src/encodeUtils.ts similarity index 51% rename from contracts/shared/helpers/bundleCompression.ts rename to contracts/clients/src/encodeUtils.ts index b3e6db3f..65437a4d 100644 --- a/contracts/shared/helpers/bundleCompression.ts +++ b/contracts/clients/src/encodeUtils.ts @@ -1,54 +1,10 @@ -import { ethers, BigNumber, BigNumberish } from "ethers"; -import { PublicKey, Operation, Signature } from "../../clients/src"; +import { BigNumber, BigNumberish, ethers } from "ethers"; -export function bundleCompressedOperations( - compressedOperations: string[], - signature: Signature, -) { - return hexJoin([ - encodeVLQ(compressedOperations.length), - ...compressedOperations, - ethers.utils.defaultAbiCoder.encode(["uint256[2]"], [signature]), - ]); -} - -export function compressAsFallback( - fallbackExpanderIndex: number, - blsPublicKey: PublicKey, - operation: Operation, -): string { - const result: string[] = []; - - result.push(encodeVLQ(fallbackExpanderIndex)); - - result.push( - ethers.utils.defaultAbiCoder.encode(["uint256[4]"], [blsPublicKey]), - ); - - result.push(encodeVLQ(operation.nonce)); - result.push(encodePseudoFloat(operation.gas)); - - result.push(encodeVLQ(operation.actions.length)); - - for (const action of operation.actions) { - result.push(encodePseudoFloat(action.ethValue)); - result.push(action.contractAddress); - - const fnHex = ethers.utils.hexlify(action.encodedFunction); - const fnLen = (fnHex.length - 2) / 2; - - result.push(encodeVLQ(fnLen)); - result.push(fnHex); - } - - return hexJoin(result); -} - -function hexJoin(hexStrings: string[]) { +export function hexJoin(hexStrings: string[]) { return "0x" + hexStrings.map(remove0x).join(""); } -function remove0x(hexString: string) { +export function remove0x(hexString: string) { if (!hexString.startsWith("0x")) { throw new Error("Expected 0x prefix"); } @@ -119,3 +75,45 @@ export function encodeRegIndex(regIndex: BigNumberish) { `0x${fixedValue.toString(16).padStart(4, "0")}`, ]); } + +/** + * Bit streams are just the bits of a uint256 encoded as a VLQ. + * (Technically the encoding is unbounded, but 256 booleans is a lot and it's + * much easier to just decode the VLQ into a uint256 in the EVM.) + * + * Notably, the bits are little endian - the first bit is the *lowest* bit. This + * is because the lowest bit is clearly the 1-valued bit, but the highest valued + * bit could be anywhere - there's infinitely many zero-bits to choose from. + * + * If it wasn't for this need to be little endian, we'd definitely use big + * endian (like our other encodings generally do), since that's preferred by the + * EVM and the ecosystem: + * + * ```ts + * const abi = new ethers.utils.AbiCoder(): + * console.log(abi.encode(["uint"], [0xff])); + * // 0x00000000000000000000000000000000000000000000000000000000000000ff + * + * // If Ethereum used little endian (like x86), it would instead be: + * // 0xff00000000000000000000000000000000000000000000000000000000000000 + * ``` + */ +export function encodeBitStream(bitStream: boolean[]) { + let stream = 0; + let bitValue = 1; + + const abi = new ethers.utils.AbiCoder(); + abi.encode(["uint"], [0xff]); + + for (const bit of bitStream) { + if (bit) { + stream += bitValue; + } + + bitValue *= 2; + } + + const streamVLQ = encodeVLQ(stream); + + return streamVLQ; +} diff --git a/contracts/clients/src/index.ts b/contracts/clients/src/index.ts index 484f84b1..2bf756e8 100644 --- a/contracts/clients/src/index.ts +++ b/contracts/clients/src/index.ts @@ -43,6 +43,9 @@ export { export { default as AddressRegistryWrapper } from "./AddressRegistryWrapper"; export { default as BlsPublicKeyRegistryWrapper } from "./BlsPublicKeyRegistryWrapper"; +export { default as FallbackCompressor } from "./FallbackCompressor"; +export { default as BundleCompressor } from "./BundleCompressor"; +export * from "./encodeUtils"; const Experimental_ = { BlsProvider, diff --git a/contracts/contracts/FallbackExpander.sol b/contracts/contracts/FallbackExpander.sol index 97edb022..5b016b84 100644 --- a/contracts/contracts/FallbackExpander.sol +++ b/contracts/contracts/FallbackExpander.sol @@ -2,6 +2,9 @@ pragma solidity >=0.7.0 <0.9.0; pragma abicoder v2; +import "./AddressRegistry.sol"; +import "./BLSPublicKeyRegistry.sol"; +import "./lib/RegIndex.sol"; import "./lib/VLQ.sol"; import "./lib/PseudoFloat.sol"; import "./interfaces/IExpander.sol"; @@ -44,16 +47,40 @@ import "./interfaces/IWallet.sol"; * discount, the saving is still over 30%.) */ contract FallbackExpander is IExpander { - function expand(bytes calldata stream) external pure returns ( + BLSPublicKeyRegistry public blsPublicKeyRegistry; + AddressRegistry public addressRegistry; + + constructor( + BLSPublicKeyRegistry blsPublicKeyRegistryParam, + AddressRegistry addressRegistryParam + ) { + blsPublicKeyRegistry = blsPublicKeyRegistryParam; + addressRegistry = addressRegistryParam; + } + + function expand(bytes calldata stream) external view returns ( uint256[4] memory senderPublicKey, IWallet.Operation memory operation, uint256 bytesRead ) { uint256 originalStreamLen = stream.length; uint256 decodedValue; + bool decodedBit; + uint256 registryUsageBitStream; + + (registryUsageBitStream, stream) = VLQ.decode(stream); - senderPublicKey = abi.decode(stream[:128], (uint256[4])); - stream = stream[128:]; + (decodedBit, registryUsageBitStream) = decodeBit( + registryUsageBitStream + ); + + if (decodedBit) { + (decodedValue, stream) = RegIndex.decode(stream); + senderPublicKey = blsPublicKeyRegistry.lookup(decodedValue); + } else { + senderPublicKey = abi.decode(stream[:128], (uint256[4])); + stream = stream[128:]; + } (decodedValue, stream) = VLQ.decode(stream); operation.nonce = decodedValue; @@ -68,8 +95,17 @@ contract FallbackExpander is IExpander { uint256 ethValue; (ethValue, stream) = PseudoFloat.decode(stream); - address contractAddress = address(bytes20(stream[:20])); - stream = stream[20:]; + address contractAddress; + + (decodedBit, registryUsageBitStream) = decodeBit(registryUsageBitStream); + + if (decodedBit) { + (decodedValue, stream) = RegIndex.decode(stream); + contractAddress = addressRegistry.lookup(decodedValue); + } else { + contractAddress = address(bytes20(stream[:20])); + stream = stream[20:]; + } (decodedValue, stream) = VLQ.decode(stream); bytes memory encodedFunction = stream[:decodedValue]; @@ -84,4 +120,8 @@ contract FallbackExpander is IExpander { bytesRead = originalStreamLen - stream.length; } + + function decodeBit(uint256 bitStream) internal pure returns (bool, uint256) { + return ((bitStream & 1) == 1, bitStream >> 1); + } } diff --git a/contracts/shared/deploy.ts b/contracts/shared/deploy.ts index 3e9ab2d7..c17d7dd2 100644 --- a/contracts/shared/deploy.ts +++ b/contracts/shared/deploy.ts @@ -2,6 +2,8 @@ import { ethers } from "ethers"; import { + AddressRegistry, + AddressRegistry__factory, AggregatorUtilities, AggregatorUtilities__factory, BLSExpander, @@ -10,8 +12,11 @@ import { BLSExpander__factory, BLSOpen, BLSOpen__factory, + BLSPublicKeyRegistry, + BLSPublicKeyRegistry__factory, BNPairingPrecompileCostEstimator, BNPairingPrecompileCostEstimator__factory, + FallbackExpander, FallbackExpander__factory, VerificationGateway, VerificationGateway__factory, @@ -25,6 +30,9 @@ export type Deployment = { blsLibrary: BLSOpen; verificationGateway: VerificationGateway; blsExpander: BLSExpander; + fallbackExpander: FallbackExpander; + blsPublicKeyRegistry: BLSPublicKeyRegistry; + addressRegistry: AddressRegistry; blsExpanderDelegator: BLSExpanderDelegator; aggregatorUtilities: AggregatorUtilities; }; @@ -67,9 +75,21 @@ export default async function deploy( salt, ); + const blsPublicKeyRegistry = await singletonFactory.connectOrDeploy( + BLSPublicKeyRegistry__factory, + [], + salt, + ); + + const addressRegistry = await singletonFactory.connectOrDeploy( + AddressRegistry__factory, + [], + salt, + ); + const fallbackExpander = await singletonFactory.connectOrDeploy( FallbackExpander__factory, - [], + [blsPublicKeyRegistry.address, addressRegistry.address], salt, ); @@ -95,6 +115,9 @@ export default async function deploy( blsLibrary, verificationGateway, blsExpander, + fallbackExpander, + blsPublicKeyRegistry, + addressRegistry, blsExpanderDelegator, aggregatorUtilities, }; diff --git a/contracts/shared/helpers/Fixture.ts b/contracts/shared/helpers/Fixture.ts index f1ee56b6..14b0712a 100644 --- a/contracts/shared/helpers/Fixture.ts +++ b/contracts/shared/helpers/Fixture.ts @@ -17,6 +17,8 @@ import { initBlsWalletSigner, Bundle, getOperationResults, + FallbackCompressor, + BundleCompressor, } from "../../clients/src"; import Range from "./Range"; @@ -53,6 +55,8 @@ export default class Fixture { public blsLibrary: BLSOpen, public blsExpander: BLSExpander, public blsExpanderDelegator: BLSExpanderDelegator, + public bundleCompressor: BundleCompressor, + public fallbackCompressor: FallbackCompressor, public utilities: AggregatorUtilities, public blsWalletSigner: BlsWalletSigner, @@ -76,6 +80,17 @@ export default class Fixture { aggregatorUtilities: utilities, } = await deploy(signers[0]); + const fallbackCompressor = await FallbackCompressor.connectIfDeployed( + signers[0], + ); + + if (fallbackCompressor === undefined) { + throw new Error("Fallback compressor not set up correctly"); + } + + const bundleCompressor = new BundleCompressor(); + bundleCompressor.addCompressor(0, fallbackCompressor); + return new Fixture( chainId, ethers.provider, @@ -85,6 +100,8 @@ export default class Fixture { bls, blsExpander, blsExpanderDelegator, + bundleCompressor, + fallbackCompressor, utilities, await initBlsWalletSigner({ chainId }), ); diff --git a/contracts/test/pseudoFloat-test.ts b/contracts/test/pseudoFloat-test.ts index 6d84a59b..c923bc1e 100644 --- a/contracts/test/pseudoFloat-test.ts +++ b/contracts/test/pseudoFloat-test.ts @@ -4,7 +4,7 @@ import { expect } from "chai"; import { BigNumber } from "ethers"; import { RLP } from "ethers/lib/utils"; import { ethers } from "hardhat"; -import { encodePseudoFloat } from "../shared/helpers/bundleCompression"; +import { encodePseudoFloat } from "../clients/src"; import { PseudoFloat, PseudoFloat__factory } from "../typechain-types"; describe("PseudoFloat", function () { diff --git a/contracts/test/regIndex-test.ts b/contracts/test/regIndex-test.ts index 3013567e..5e095d78 100644 --- a/contracts/test/regIndex-test.ts +++ b/contracts/test/regIndex-test.ts @@ -3,7 +3,7 @@ import { expect } from "chai"; import { BigNumber } from "ethers"; import { ethers } from "hardhat"; -import { encodeRegIndex } from "../shared/helpers/bundleCompression"; +import { encodeRegIndex } from "../clients/src"; import { RegIndex, RegIndex__factory } from "../typechain-types"; describe("RegIndex", function () { diff --git a/contracts/test/walletAction-test.ts b/contracts/test/walletAction-test.ts index 86e83a7b..9fa99a9b 100644 --- a/contracts/test/walletAction-test.ts +++ b/contracts/test/walletAction-test.ts @@ -7,10 +7,6 @@ import TokenHelper from "../shared/helpers/TokenHelper"; import { BigNumber, ContractReceipt } from "ethers"; import { parseEther, solidityPack } from "ethers/lib/utils"; -import { - bundleCompressedOperations, - compressAsFallback, -} from "../shared/helpers/bundleCompression"; import { getOperationResults } from "../clients/src"; describe("WalletActions", async function () { @@ -195,20 +191,71 @@ describe("WalletActions", async function () { ], }); - await ( - await fx.blsExpanderDelegator.run( - bundleCompressedOperations( - [ - compressAsFallback( - Fixture.expanderIndexes.fallback, - bundle.senderPublicKeys[0], - bundle.operations[0], - ), - ], - bundle.signature, - ), - ) - ).wait(); + const compressedBundle = await fx.bundleCompressor.compress(bundle); + + await (await fx.blsExpanderDelegator.run(compressedBundle)).wait(); + + await expect( + fx.provider.getBalance(sendWallet.address), + ).to.eventually.equal(0); + await expect( + fx.provider.getBalance(mockAuction.address), + ).to.eventually.equal(ethToTransfer); + }); + + it("should send ETH with function call via fallback expander and registries", async function () { + // send money to sender bls wallet + const sendWallet = await fx.createBLSWallet(); + const ethToTransfer = parseEther("0.001"); + await fx.signers[0].sendTransaction({ + to: sendWallet.address, + value: ethToTransfer, + }); + + const MockAuction = await ethers.getContractFactory("MockAuction"); + const mockAuction = await MockAuction.deploy(); + await mockAuction.deployed(); + + await expect( + fx.provider.getBalance(sendWallet.address), + ).to.eventually.equal(ethToTransfer); + await expect( + fx.provider.getBalance(mockAuction.address), + ).to.eventually.equal(0); + + const bundle = await sendWallet.signWithGasEstimate({ + nonce: 0, + actions: [ + { + ethValue: ethToTransfer, + contractAddress: mockAuction.address, + encodedFunction: mockAuction.interface.encodeFunctionData("buyItNow"), + }, + ], + }); + + let compressedBundle = await fx.bundleCompressor.compress(bundle); + const initialCompressedSize = hexLen(compressedBundle); + + await fx.fallbackCompressor.blsPublicKeyRegistry.register( + sendWallet.PublicKey(), + ); + + compressedBundle = await fx.bundleCompressor.compress(bundle); + const blsRegSaving = initialCompressedSize - hexLen(compressedBundle); + + await Promise.all([ + fx.fallbackCompressor.addressRegistry.register(sendWallet.address), + fx.fallbackCompressor.addressRegistry.register(mockAuction.address), + ]); + + compressedBundle = await fx.bundleCompressor.compress(bundle); + const totalRegSaving = initialCompressedSize - hexLen(compressedBundle); + + expect(blsRegSaving).to.be.greaterThan(0); + expect(totalRegSaving).to.be.greaterThan(blsRegSaving); + + await (await fx.blsExpanderDelegator.run(compressedBundle)).wait(); await expect( fx.provider.getBalance(sendWallet.address), @@ -532,3 +579,11 @@ describe("WalletActions", async function () { // expect(balanceAfter.sub(balanceBefore)).to.equal(rewardAmountToSend); // }) }); + +function hexLen(str: string) { + if (!str.startsWith("0x")) { + throw new Error("Not a hex string"); + } + + return (str.length - 2) / 2; +}