Skip to content

Commit

Permalink
Support for taking state as an argument and for returning it (#10)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Arachnid authored Jun 18, 2021
1 parent 1d3cc59 commit 6c4416c
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 40 deletions.
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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();
```
90 changes: 66 additions & 24 deletions src/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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}`);
Expand Down Expand Up @@ -153,28 +170,46 @@ 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");
}
}
}

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[]} {
Expand All @@ -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}'`);
}
}
}
Expand All @@ -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;
Expand All @@ -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?
Expand All @@ -261,6 +301,8 @@ export class Planner {
if(isDynamicType(call.fragment.outputs[0])) {
ret |= 0x80;
}
} else if(replacesState) {
ret = 0xfe;
}

commands.push(hexConcat([
Expand Down
45 changes: 30 additions & 15 deletions tests/test_planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -72,16 +72,16 @@ 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);
})

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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
});
});

0 comments on commit 6c4416c

Please sign in to comment.