From 6c4416c1335764fb321929b3301260d3f20a4e41 Mon Sep 17 00:00:00 2001 From: Nick Johnson Date: Sat, 19 Jun 2021 08:43:26 +1200 Subject: [PATCH] Support for taking state as an argument and for returning it (#10) * Convert LiteralValue and ReturnValue to classes * Add support for calls that take state as input * Add support for replacing the state with a return value * Add additional check for return values that replace state * Write README * Use add instead of addCommand --- README.md | 50 +++++++++++++++++++++++- src/planner.ts | 90 +++++++++++++++++++++++++++++++------------ tests/test_planner.ts | 45 ++++++++++++++-------- 3 files changed, 145 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 482e47a..530651c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ # weiroll.js -The Weiroll planner for JS +weiroll.js is a planner for the operation-chaining/scripting language [weiroll](https://github.com/weiroll/weiroll). + +It provides an easy-to-use API for generating weiroll programs that can then be passed to any compatible implementation. + +## Installation +``` +npm install --save @weiroll/weiroll.js +``` + +## Usage + +### Wrapping contracts +Weiroll programs consist of a sequence of delegatecalls to library functions in external contracts. Before you can start creating a weiroll program, you will need to create interfaces for at least one library contract you intend to use. + +The easiest way to do this is by wrapping ethers.js contract instances: + +``` +const ethersContract = new ethers.Contract(address, abi); +const contract = weiroll.Contract.fromEthersContract(ethersContract); +``` + +You can repeat this for each library contract you wish to use. A weiroll `Contract` object can be reused across as many planner instances as you wish; there is no need to construct them again for each new program. + +### Planning programs +First, instantiate a planner: + +``` +const planner = new weiroll.Planner(); +``` + +Next, add one or more commands to execute: + +``` +const ret = planner.addCommand(contract.func(a, b)); +``` + +Return values from one invocation can be used in another one: + +``` +planner.addCommand(contract.func2(ret)); +``` + +Remember to wrap each call to a contract in `planner.addCommand`. Attempting to pass the result of one contract function directly to another will not work - each one needs to be added to the planner! + +Once you are done planning operations, generate the program: + +``` +const {commands, state} = planner.plan(); +``` diff --git a/src/planner.ts b/src/planner.ts index c87c2de..e729804 100644 --- a/src/planner.ts +++ b/src/planner.ts @@ -12,21 +12,38 @@ export interface Value { readonly param: ParamType; } -export interface LiteralValue extends Value { - readonly value: string; +function isValue(arg:any): arg is Value { + return (arg as Value).param !== undefined; } -export function isLiteralValue(value: any): value is LiteralValue { - return (value as LiteralValue).value !== undefined; +export class LiteralValue implements Value { + readonly param: ParamType; + readonly value: string; + + constructor(param: ParamType, value: string) { + defineReadOnly(this, "param", param); + defineReadOnly(this, "value", value); + } } -export interface ReturnValue extends Value { +export class ReturnValue implements Value { + readonly param: ParamType; readonly planner: Planner; readonly commandIndex: number; // Index of the command in the array of planned commands + + constructor(param: ParamType, planner: Planner, commandIndex: number) { + defineReadOnly(this, "param", param); + defineReadOnly(this, "planner", planner); + defineReadOnly(this, "commandIndex", commandIndex); + } } -export function isReturnValue(value: any): value is ReturnValue { - return (value as ReturnValue).commandIndex !== undefined; +export class StateValue implements Value { + readonly param: ParamType; + + constructor() { + defineReadOnly(this, "param", ParamType.from('bytes[]')); + } } export interface FunctionCall { @@ -46,9 +63,9 @@ export function isDynamicType(param: ParamType): boolean { function abiEncodeSingle(param: ParamType, value: any): LiteralValue { if(isDynamicType(param)) { - return {param: param, value: hexDataSlice(defaultAbiCoder.encode([param], [value]), 32)}; + return new LiteralValue(param, hexDataSlice(defaultAbiCoder.encode([param], [value]), 32)); } - return {param: param, value: defaultAbiCoder.encode([param], [value])}; + return new LiteralValue(param, defaultAbiCoder.encode([param], [value])); } function buildCall(contract: Contract, fragment: FunctionFragment): ContractFunction { @@ -58,7 +75,7 @@ function buildCall(contract: Contract, fragment: FunctionFragment): ContractFunc } const encodedArgs = args.map((arg, idx) => { const param = fragment.inputs[idx]; - if(isReturnValue(arg)) { + if(isValue(arg)) { if(arg.param.type != param.type) { // Todo: type casting rules throw new Error(`Cannot pass value of type ${arg.param.type} to input of type ${param.type}`); @@ -153,15 +170,17 @@ export class Contract extends BaseContract { } export class Planner { - calls: FunctionCall[]; + readonly state: StateValue; + calls: {call: FunctionCall, replacesState: boolean}[]; constructor() { + defineReadOnly(this, "state", new StateValue()); this.calls = []; } - addCommand(call: FunctionCall): ReturnValue | null { + add(call: FunctionCall): ReturnValue | null { for(let arg of call.args) { - if(isReturnValue(arg)) { + if(arg instanceof ReturnValue) { if(arg.planner != this) { throw new Error("Cannot reuse return values across planners"); } @@ -169,12 +188,28 @@ export class Planner { } const commandIndex = this.calls.length; - this.calls.push(call); + this.calls.push({call, replacesState: false}); if(call.fragment.outputs.length != 1) { return null; } - return {planner: this, commandIndex, param: call.fragment.outputs[0]}; + return new ReturnValue(call.fragment.outputs[0], this, commandIndex); + } + + replaceState(call: FunctionCall) { + for(let arg of call.args) { + if(arg instanceof ReturnValue) { + if(arg.planner != this) { + throw new Error("Cannot reuse return values across planners"); + } + } + } + + if(call.fragment.outputs.length != 1 || call.fragment.outputs[0].type != 'bytes[]') { + throw new Error("Function replacing state must return a bytes[]"); + } + + this.calls.push({call, replacesState: true}); } plan(): {commands: string[], state: string[]} { @@ -185,14 +220,14 @@ export class Planner { // Build visibility maps for(let i = 0; i < this.calls.length; i++) { - const call = this.calls[i]; + const {call} = this.calls[i]; for(let arg of call.args) { - if(isReturnValue(arg)) { + if(arg instanceof ReturnValue) { commandVisibility[arg.commandIndex] = i; - } else if(isLiteralValue(arg)) { + } else if(arg instanceof LiteralValue) { literalVisibility.set(arg.value, i); - } else { - throw new Error("Unknown function argument type"); + } else if(!(arg instanceof StateValue)) { + throw new Error(`Unknown function argument type '${typeof arg}'`); } } } @@ -219,18 +254,20 @@ export class Planner { // Build commands, and add state entries as needed for(let i = 0; i < this.calls.length; i++) { - const call = this.calls[i]; + const {call, replacesState} = this.calls[i]; // Build a list of argument value indexes const args = new Uint8Array(7).fill(0xff); call.args.forEach((arg, j) => { let slot; - if(isReturnValue(arg)) { + if(arg instanceof ReturnValue) { slot = returnSlotMap[arg.commandIndex]; - } else if(isLiteralValue(arg)) { + } else if(arg instanceof LiteralValue) { slot = literalSlotMap.get(arg.value); + } else if(arg instanceof StateValue) { + slot = 0xfe; } else { - throw new Error("Unknown function argument type"); + throw new Error(`Unknown function argument type '${typeof arg}'`); } if(isDynamicType(arg.param)) { slot |= 0x80; @@ -241,6 +278,9 @@ export class Planner { // Figure out where to put the return value let ret = 0xff; if(commandVisibility[i] != -1) { + if(replacesState) { + throw new Error(`Return value of ${call.fragment.name} cannot be used to replace state and in another function`); + } ret = state.length; // Is there a spare state slot? @@ -261,6 +301,8 @@ export class Planner { if(isDynamicType(call.fragment.outputs[0])) { ret |= 0x80; } + } else if(replacesState) { + ret = 0xfe; } commands.push(hexConcat([ diff --git a/tests/test_planner.ts b/tests/test_planner.ts index e62fa41..4d856b9 100644 --- a/tests/test_planner.ts +++ b/tests/test_planner.ts @@ -47,9 +47,9 @@ describe('Planner', () => { it('adds function calls to a list of commands', () => { const planner = new Planner(); - const sum1 = planner.addCommand(Math.add(1, 2)); - const sum2 = planner.addCommand(Math.add(3, 4)); - const sum3 = planner.addCommand(Math.add(sum1, sum2)); + const sum1 = planner.add(Math.add(1, 2)); + const sum2 = planner.add(Math.add(3, 4)); + const sum3 = planner.add(Math.add(sum1, sum2)); expect(planner.calls.length).to.equal(3); expect(sum1.commandIndex).to.equal(0); @@ -59,7 +59,7 @@ describe('Planner', () => { it('plans a simple program', () => { const planner = new Planner(); - planner.addCommand(Math.add(1, 2)); + planner.add(Math.add(1, 2)); const {commands, state} = planner.plan(); expect(commands.length).to.equal(1); @@ -72,7 +72,7 @@ describe('Planner', () => { it('deduplicates identical literals', () => { const planner = new Planner(); - const sum1 = planner.addCommand(Math.add(1, 1)); + const sum1 = planner.add(Math.add(1, 1)); const {commands, state} = planner.plan(); expect(state.length).to.equal(1); @@ -80,8 +80,8 @@ describe('Planner', () => { it('plans a program that uses return values', () => { const planner = new Planner(); - const sum1 = planner.addCommand(Math.add(1, 2)); - planner.addCommand(Math.add(sum1, 3)); + const sum1 = planner.add(Math.add(1, 2)); + planner.add(Math.add(sum1, 3)); const {commands, state} = planner.plan(); expect(commands.length).to.equal(2); @@ -96,8 +96,8 @@ describe('Planner', () => { it('plans a program that needs extra state slots for intermediate values', () => { const planner = new Planner(); - const sum1 = planner.addCommand(Math.add(1, 1)); - planner.addCommand(Math.add(1, sum1)); + const sum1 = planner.add(Math.add(1, 1)); + planner.add(Math.add(1, sum1)); const {commands, state} = planner.plan(); expect(commands.length).to.equal(2); @@ -111,7 +111,7 @@ describe('Planner', () => { it('plans a program that takes dynamic arguments', () => { const planner = new Planner(); - planner.addCommand(Strings.strlen("Hello, world!")); + planner.add(Strings.strlen("Hello, world!")); const {commands, state} = planner.plan(); expect(commands.length).to.equal(1); @@ -123,7 +123,7 @@ describe('Planner', () => { it('plans a program that returns dynamic arguments', () => { const planner = new Planner(); - planner.addCommand(Strings.strcat("Hello, ", "world!")); + planner.add(Strings.strcat("Hello, ", "world!")); const {commands, state} = planner.plan(); expect(commands.length).to.equal(1); @@ -136,8 +136,8 @@ describe('Planner', () => { it('plans a program that takes a dynamic argument from a return value', () => { const planner = new Planner(); - const str = planner.addCommand(Strings.strcat("Hello, ", "world!")); - planner.addCommand(Strings.strlen(str)); + const str = planner.add(Strings.strcat("Hello, ", "world!")); + planner.add(Strings.strlen(str)); const {commands, state} = planner.plan(); expect(commands.length).to.equal(2); @@ -151,6 +151,21 @@ describe('Planner', () => { it('requires argument counts to match the function definition', () => { const planner = new Planner(); - expect(() => planner.addCommand(Math.add(1))).to.throw(); - }) + expect(() => planner.add(Math.add(1))).to.throw(); + }); + + it('plans a call to a function that takes and replaces the current state', () => { + const TestContract = Contract.fromEthersContract(new ethers.Contract(SAMPLE_ADDRESS, [ + "function useState(bytes[] state) returns(bytes[])" + ])); + + const planner = new Planner(); + planner.replaceState(TestContract.useState(planner.state)); + const {commands, state} = planner.plan(); + + expect(commands.length).to.equal(1); + expect(commands[0]).to.equal("0x08f389c8fefffffffffffffeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + + expect(state.length).to.equal(0); + }); });