nano-wallet-js
Version:
SDK for developers to create and interact with Nanocurrency wallet easily
475 lines (407 loc) • 10.3 kB
text/typescript
import {
Unit,
convert,
createBlock,
deriveAddress,
derivePublicKey,
validateWork,
} from 'nanocurrency';
import NanoRPC, { NanoRpcConfig } from '../rpc/RpcController';
import {
DEFAULT_TIMEOUT,
MIN_AMOUNT,
RECEIVE_DIFFICULTY,
SEND_DIFFICULTY,
} from '@/Constants';
import BaseController from '@/BaseController';
import { TunedBigNumber } from '@/utils';
import Logger from '@/logger/Logger';
import Locker from './Locker';
export interface NanoWalletConfig extends NanoRpcConfig {
privateKey: string;
representative: string;
minAmountRaw?: string;
precomputeWork?: boolean;
}
export interface ReceivableBlock {
blockHash: string;
amount: string;
}
interface Work {
hash: string;
threshold: string;
work: string;
}
export interface NanoWalletState {
balance: string;
receivable: string;
receivableBlocks: ReceivableBlock[];
frontier: string | null;
representative: string | null;
work: Work | null;
}
export default class NanoWallet extends BaseController<
NanoWalletConfig,
NanoWalletState
> {
rpc: NanoRPC;
publicKey: string;
account: string;
defaultConfig = {
rpcUrls: [],
workerUrls: [],
privateKey: null,
representative: '',
precomputeWork: true,
minAmountRaw: convert(MIN_AMOUNT.toString(), {
from: Unit.NANO,
to: Unit.raw,
}),
timeout: DEFAULT_TIMEOUT,
debug: false,
} as any as NanoWalletConfig;
defaultState: NanoWalletState = {
balance: '0',
receivable: '0',
receivableBlocks: [],
frontier: null,
representative: null,
work: null,
};
logger: Logger;
locker = new Locker();
constructor(config: NanoWalletConfig, state?: NanoWalletState | null) {
super(config, state || undefined);
this.publicKey = derivePublicKey(config.privateKey);
this.account = deriveAddress(this.publicKey, { useNanoPrefix: true });
this.logger = new Logger('NANO_WALLET', config.debug);
this.logger.info(`Imported account: ${this.account}`);
this.initialize();
this.rpc = new NanoRPC({
rpcUrls: this.config.rpcUrls,
workerUrls: this.config.workerUrls,
timeout: this.config.timeout,
debug: this.config.debug,
});
let previousFrontier = this.state.frontier;
if (this.config.precomputeWork) {
this.subscribe(state => {
if (state.work && state.work.hash !== state.frontier) {
this.update({ work: null });
}
if (
state.frontier &&
state.frontier !== previousFrontier &&
(!state.work || state.work.hash !== state.frontier)
) {
this.logger.info('Precomputing Work for', state.frontier);
this.getWork(state.frontier, SEND_DIFFICULTY);
}
previousFrontier = state.frontier;
});
}
}
async sync() {
try {
const { balance, frontier, receivable, representative } =
await this.rpc.accountInfo(this.account);
this.logger.info(
`Wallet Sync! Balance: ${convert(balance, {
from: Unit.raw,
to: Unit.NANO,
})} NANO. Receivable: ${convert(receivable, {
from: Unit.raw,
to: Unit.NANO,
})}`,
);
this.update({ balance, frontier, receivable, representative });
} catch (error: any) {
if (error.message !== 'Account not found') {
this.logger.error(
'sync:',
error instanceof Error ? error.message : error,
);
throw error;
}
}
await this.getReceivable();
}
private async workGenerate(hash: string, threshold: string) {
try {
const startedAt = Date.now();
const { work } = await this.rpc.workGenerate(hash, threshold);
if (!work) {
throw new Error('No work');
}
const isValidWork = validateWork({
work,
blockHash: hash,
threshold,
});
if (!isValidWork) {
throw new Error('Invalid work');
}
this.logger.info(
`Generated Work for ${hash} in ${Date.now() - startedAt} ms`,
);
return work;
} catch (error) {
this.logger.error(
'workGenerate:',
error instanceof Error ? error.message : error,
);
throw error;
}
}
private async getWork(hash: string, threshold: string) {
if (
this.state.work?.hash === hash &&
parseInt(this.state.work.threshold, 16) >= parseInt(threshold, 16)
) {
this.logger.info(`Using precomputed Work for ${hash}`);
return this.state.work.work;
}
const work = await this.workGenerate(hash, threshold);
// TODO: Store the generated threshold, instead the requested one
this.update({
work: {
hash,
threshold,
work,
},
});
return work;
}
async getReceivable() {
try {
const { blocks = {} } = await this.rpc.receivable(this.account, {
threshold: this.config.minAmountRaw,
});
let receivableBlocks: ReceivableBlock[] = [];
let receivable = '0';
for (const blockHash in blocks) {
receivableBlocks.push({
blockHash,
amount: blocks[blockHash],
});
receivable = TunedBigNumber(receivable)
.plus(blocks[blockHash])
.toString();
}
this.logger.info(
`${convert(receivable, {
from: Unit.raw,
to: Unit.NANO,
})} NANO to receive from ${receivableBlocks.length} blocks`,
);
this.update({ receivableBlocks, receivable });
return { receivableBlocks, receivable };
} catch (error) {
this.logger.error(
'getReceivable',
error instanceof Error ? error.message : error,
);
throw error;
}
}
async receive(link: string) {
await this.locker.acquire();
try {
link = link.toUpperCase();
const amount = this.state.receivableBlocks.find(
({ blockHash }) => blockHash === link,
)?.amount;
if (!amount) {
throw new Error('No receivable block');
}
const balance = TunedBigNumber(this.state.balance)
.plus(amount)
.toString();
this.logger.info(
`Receiving ${convert(amount, {
from: Unit.raw,
to: Unit.NANO,
})} NANO from block ${link}`,
);
const { block, hash } = createBlock(this.config.privateKey, {
previous: this.state.frontier,
representative: this.config.representative,
balance,
link,
work: null,
});
const frontier = this.state.frontier || this.publicKey;
const work = await this.getWork(frontier, RECEIVE_DIFFICULTY);
const processed = await this.rpc.process({
...block,
work,
});
if (processed.hash !== hash) {
throw new Error('Block hash mismatch');
}
const receivableBlocks = this.state.receivableBlocks.filter(
({ blockHash }) => blockHash !== link,
);
const receivable = TunedBigNumber(this.state.receivable)
.minus(amount)
.toString();
this.update({
balance,
frontier: hash,
receivableBlocks,
receivable,
});
return { hash };
} catch (error) {
this.logger.error(
'receive:',
error instanceof Error ? error.message : error,
);
throw error;
} finally {
await this.locker.release();
}
}
async send(to: string, amount: string) {
await this.locker.acquire();
try {
if (this.state.frontier === null) {
throw new Error('No frontier');
}
const balance = TunedBigNumber(this.state.balance)
.minus(amount)
.toString();
this.logger.info(
`Sending ${convert(amount, {
from: Unit.raw,
to: Unit.NANO,
})} NANO to ${to}`,
);
const { block, hash } = createBlock(this.config.privateKey, {
previous: this.state.frontier,
representative: this.config.representative,
balance,
link: to,
work: null,
});
const work = await this.getWork(this.state.frontier, SEND_DIFFICULTY);
const processed = await this.rpc.process({
...block,
work,
});
if (processed.hash !== hash) {
throw new Error('Block hash mismatch');
}
this.update({
balance,
frontier: hash,
});
return { hash };
} catch (error) {
this.logger.error(
'send:',
error instanceof Error ? error.message : error,
);
throw error;
} finally {
this.locker.release();
}
}
async sweep(to: string) {
await this.locker.acquire();
try {
if (this.state.frontier === null) {
throw new Error('No frontier');
}
this.logger.info(`Sweeping all funds from ${this.account} to ${to}`);
const { block, hash } = createBlock(this.config.privateKey, {
previous: this.state.frontier,
representative: this.config.representative,
balance: '0',
link: to,
work: null,
});
const work = await this.getWork(this.state.frontier, SEND_DIFFICULTY);
const processed = await this.rpc.process({
...block,
work,
});
if (processed.hash !== hash) {
throw new Error('Block hash mismatch');
}
this.update({
balance: '0',
frontier: hash,
});
return { hash };
} catch (error) {
this.logger.error(
'sweep:',
error instanceof Error ? error.message : error,
);
throw error;
} finally {
this.locker.release();
}
}
async setRepresentative(account?: string) {
await this.locker.acquire();
try {
if (this.state.frontier === null) {
throw new Error('No frontier');
}
this.logger.info(`Setting representative: ${account}`);
const representative = account || this.config.representative;
const { block, hash } = createBlock(this.config.privateKey, {
previous: this.state.frontier,
representative,
balance: this.state.balance,
link: null,
work: null,
});
const work = await this.getWork(this.state.frontier, SEND_DIFFICULTY);
const processed = await this.rpc.process({
...block,
work,
});
if (processed.hash !== hash) {
throw new Error('Block hash mismatch');
}
this.update({
balance: '0',
frontier: hash,
representative,
});
this.configure({
representative,
});
return { hash };
} catch (error) {
this.logger.error(
'setRepresentative:',
error instanceof Error ? error.message : error,
);
throw error;
} finally {
this.locker.release();
}
}
get balance() {
return this.state.balance;
}
get receivable() {
return this.state.receivable;
}
get receivableBlocks() {
return this.state.receivableBlocks;
}
get frontier() {
return this.state.frontier;
}
// Current representative may be different from the initialized representative
// if after initialization of instance there has not yet been a new transaction or
// .setRepresentative() has not been called yet.
get currentRepresentative() {
return this.state.representative;
}
}