Skip to content

Commit

Permalink
TransactionController implementation (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
bitpshr authored Aug 15, 2018
1 parent 4655194 commit 26a8b6a
Show file tree
Hide file tree
Showing 18 changed files with 1,202 additions and 168 deletions.
54 changes: 36 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ The **GABA** engine is a collection of platform-agnostic modules for creating se
TokenRatesController
} from 'gaba';

const datamodel = new ComposableController({
networkStatus: new NetworkStatusController(),
tokenRates: new TokenRatesController()
});
const datamodel = new ComposableController([
new NetworkStatusController(),
new TokenRatesController()
]);

datamodel.subscribe((state) => {/* data model has changed */});
```
Expand Down Expand Up @@ -71,6 +71,14 @@ import BlockHistoryController from 'gaba';

The BlockHistoryController maintains a set number of past blocks that are backfilled upon initialization.

### ComposableController

```ts
import ComposableController from 'gaba';
```

The ComposableController can be used to compose mutiple controllers together into a single controller.

### CurrencyRateController

```ts
Expand Down Expand Up @@ -133,6 +141,16 @@ The ShapeShiftController exposes functions for creating ShapeShift purchases and
import TokenRatesController from 'gaba';
```

The TokenRatesController passively polls on a set interval for token-to-fiat exchange rates.

### TransactionController

```ts
import TransactionController from 'gaba';
```

The TransactionController is responsible for submitting and managing transactions.

### util

```ts
Expand Down Expand Up @@ -225,19 +243,19 @@ controller.unsubscribe(onChange);

Because each GABA module maintains its own state and subscriptions, it would be tedious to initialize and subscribe to every available module independently. To solve this issue, the ComposableController can be used to compose multiple GABA modules into a single controller.

The ComposableController is initialized by passing an object mapping unique names to child GABA module instances:
The ComposableController is initialized by passing an array of GABA module instances:

```ts
    import {
        ComposableController,
        NetworkStatusController,
        TokenRatesController
    } from 'gaba';

    const datamodel = new ComposableController({
        networkStatus: new NetworkStatusController(),
        tokenRates: new TokenRatesController()
    });
import {
ComposableController,
NetworkStatusController,
TokenRatesController
} from 'gaba';

const datamodel = new ComposableController([
new NetworkStatusController(),
new TokenRatesController()
]);
```

The resulting composed module exposes the same APIs as every other GABA module for configuration, state management, and subscription:
Expand All @@ -246,14 +264,14 @@ The resulting composed module exposes the same APIs as every other GABA module f
datamodel.subscribe((state) => { /* some child state has changed */ });
```

The internal state maintained by a ComposableController will be keyed by the same object keys used during initialization. It's also possible to access the `flatState` instance variable that is a convenience accessor for merged child state:
The internal state maintained by a ComposableController will be keyed by child controller class name. It's also possible to access the `flatState` instance variable that is a convenience accessor for merged child state:

```ts
console.log(datamodel.state); // {networkStatus: {...}, tokenRates: {...}}
console.log(datamodel.state); // {NetworkController: {...}, TokenRatesController: {...}}
console.log(datamodel.flatState); // {infura: {...}, contractExchangeRates: [...]}
```

**Advanced Note:** The object used to initialize a ComposableController is cached as a `context` instance variable on both the ComposableController itself as well as all child GABA modules. This means that child modules can call methods on other sibling modules through the `context` variable, e.g. `this.context.someController.someMethod()`.
**Advanced Note:** The ComposableController builds a map of all child controllers keyed by controller name. This object is cached as a `context` instance variable on both the ComposableController itself as well as all child GABA modules. This means that child modules can call methods on other sibling modules through the `context` variable, e.g. `this.context.SomeController.someMethod()`.

## Why TypeScript?

Expand Down
74 changes: 71 additions & 3 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,14 @@
},
"dependencies": {
"await-semaphore": "^0.1.3",
"eth-block-tracker": "3.0.1",
"eth-block-tracker": "git://github.com/metamask/eth-block-tracker.git#3.0.1-subscription-fix",
"eth-json-rpc-infura": "^3.1.2",
"eth-keyring-controller": "^4.0.0",
"eth-phishing-detect": "^1.1.13",
"eth-query": "^2.1.2",
"ethereumjs-util": "^5.2.0",
"ethereumjs-wallet": "0.6.0",
"ethjs-query": "^0.3.8",
"percentile": "^1.2.1",
"web3-provider-engine": "^14.0.5"
}
Expand Down
11 changes: 11 additions & 0 deletions src/BaseController.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { stub } from 'sinon';
import BaseController, { BaseConfig, BaseState } from './BaseController';
import ComposableController from './ComposableController';

const STATE = { name: 'foo' };
const CONFIG = { disabled: true };

class TestController extends BaseController<BaseState, BaseConfig> {
requiredControllers = ['Foo'];

constructor(state?: BaseState, config?: BaseConfig) {
super(state, config);
this.initialize();
Expand Down Expand Up @@ -58,4 +61,12 @@ describe('BaseController', () => {
controller.notify();
expect(listener.called).toBe(false);
});

it('should throw if siblings are missing dependencies', () => {
const controller = new TestController();
expect(() => {
/* tslint:disable:no-unused-expression */
new ComposableController([controller]);
}).toThrow();
});
});
13 changes: 11 additions & 2 deletions src/BaseController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export class BaseController<S extends BaseState, C extends BaseConfig> {
*/
disabled = false;

/**
* List of required sibling controllers this controller needs to function
*/
requiredControllers: string[] = [];

private initialConfig: C;
private initialState: S;
private internalConfig: C = this.defaultConfig;
Expand All @@ -78,7 +83,7 @@ export class BaseController<S extends BaseState, C extends BaseConfig> {
* variable on this instance and triggers any defined setters. This
* also sets initial state and triggers any listeners.
*
* @returns This controller instance
* @returns - This controller instance
*/
protected initialize() {
this.internalState = this.defaultState;
Expand Down Expand Up @@ -139,7 +144,11 @@ export class BaseController<S extends BaseState, C extends BaseConfig> {
* with other controllers using a ComposableController
*/
onComposed() {
/* tslint:disable-next-line:no-empty */
this.requiredControllers.forEach((name) => {
if (!this.context[name]) {
throw new Error(`${this.constructor.name} must be composed with ${name}.`);
}
});
}

/**
Expand Down
20 changes: 17 additions & 3 deletions src/BlockHistoryController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { stub } from 'sinon';
const BlockTracker = require('eth-block-tracker');
const HttpProvider = require('ethjs-provider-http');

const TEST_BLOCK_NUMBER = 3394900;
const PROVIDER = new HttpProvider('https://ropsten.infura.io');

describe('BlockHistoryController', () => {
Expand All @@ -24,6 +23,16 @@ describe('BlockHistoryController', () => {
});
});

it('should not update state if not backfilled', () => {
return new Promise((resolve) => {
const blockTracker = new BlockTracker({ provider: PROVIDER });
const controller = new BlockHistoryController(undefined, { blockTracker });
blockTracker.emit('block', { number: 1337, transactions: [] });
expect(controller.state.recentBlocks).toEqual([]);
resolve();
});
});

it('should add new block to recentBlocks state', () => {
return new Promise((resolve) => {
const blockTracker = new BlockTracker({ provider: PROVIDER });
Expand All @@ -36,7 +45,6 @@ describe('BlockHistoryController', () => {
resolve();
});
});
blockTracker.emit('block', { number: TEST_BLOCK_NUMBER.toString(16) });
});
});

Expand All @@ -52,7 +60,6 @@ describe('BlockHistoryController', () => {
expect(state.recentBlocks.length).toBe(50);
resolve();
});
blockTracker.emit('block', { number: TEST_BLOCK_NUMBER.toString(16) });
});
});

Expand All @@ -70,4 +77,11 @@ describe('BlockHistoryController', () => {
controller.blockTracker = new EventEmitter();
expect((mockBlockTracker.removeAllListeners as any).called).toBe(true);
});

it('should get latest block', async () => {
const blockTracker = new BlockTracker({ provider: PROVIDER });
const controller = new BlockHistoryController(undefined, { blockTracker, provider: PROVIDER });
const block = await controller.getLatestBlock();
expect(block).toHaveProperty('number');
});
});
39 changes: 34 additions & 5 deletions src/BlockHistoryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,25 @@ export class BlockHistoryController extends BaseController<BlockHistoryState, Bl
private ethQuery: any;
private internalBlockDepth = 0;
private internalBlockTracker: any;
private processing: Promise<void> | undefined;

private backfill() {
this.backfilled = false;
this.internalBlockTracker &&
this.internalBlockTracker.once('block', async (block: Block) => {
if (!this.internalBlockTracker || !this.ethQuery) {
return;
}
this.processing = new Promise(async (done, fail) => {
try {
this.backfilled = false;
const block = (await new Promise((resolve, reject) => {
this.ethQuery.getBlockByNumber('latest', true, (error: Error, latestBlock: Block) => {
/* istanbul ignore if */
if (error) {
reject(error);
return;
}
resolve(latestBlock);
});
})) as Block;
const currentBlockNumber = Number.parseInt(block.number, 16);
const blocksToFetch = Math.min(currentBlockNumber, this.internalBlockDepth);
const blockNumbers = Array(blocksToFetch)
Expand All @@ -106,7 +120,12 @@ export class BlockHistoryController extends BaseController<BlockHistoryState, Bl
});
this.update({ recentBlocks });
this.backfilled = true;
});
done();
} catch (error) {
/* istanbul ignore next */
fail(error);
}
});
}

private getBlockByNumber(blockNumber: number): Promise<Block> {
Expand Down Expand Up @@ -155,7 +174,6 @@ export class BlockHistoryController extends BaseController<BlockHistoryState, Bl
provider: undefined
};
this.initialize();
this.backfill();
}

/**
Expand Down Expand Up @@ -193,6 +211,17 @@ export class BlockHistoryController extends BaseController<BlockHistoryState, Bl
this.ethQuery = new EthQuery(provider);
this.backfill();
}

/**
* Returns the latest block for the current network
*
* @returns - Promise resolving to the latest block
*/
async getLatestBlock() {
await this.processing;
const { recentBlocks } = this.state;
return recentBlocks[recentBlocks.length - 1];
}
}

export default BlockHistoryController;
Loading

0 comments on commit 26a8b6a

Please sign in to comment.