563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
import { getSecureRandomBytes, keyPairFromSeed } from "ton-crypto";
|
|
import { backoff } from "../utils/backoff";
|
|
import { toUrlSafe } from "../utils/toURLsafe";
|
|
import * as t from 'io-ts';
|
|
import { delay } from "teslabot";
|
|
import { Cell, Address, beginCell, CommentMessage, safeSign, safeSignVerify } from 'ton';
|
|
import BN from 'bn.js';
|
|
import { TonhubHttpTransport } from '../transport/TonhubHttpTransport';
|
|
import { extractPublicKeyAndAddress } from "../contracts/extractPublicKeyAndAddress";
|
|
import { verifySignatureResponse } from "./crypto";
|
|
import { Transport } from '../transport/Transport';
|
|
|
|
const sessionStateCodec = t.union([
|
|
t.type({
|
|
state: t.literal('not_found')
|
|
}),
|
|
t.type({
|
|
state: t.literal('initing'),
|
|
name: t.string,
|
|
url: t.string,
|
|
testnet: t.boolean,
|
|
created: t.number,
|
|
updated: t.number,
|
|
revoked: t.boolean
|
|
}),
|
|
t.type({
|
|
state: t.literal('ready'),
|
|
name: t.string,
|
|
url: t.string,
|
|
wallet: t.type({
|
|
address: t.string,
|
|
endpoint: t.string,
|
|
walletConfig: t.string,
|
|
walletType: t.string,
|
|
walletSig: t.string,
|
|
appPublicKey: t.string
|
|
}),
|
|
testnet: t.boolean,
|
|
created: t.number,
|
|
updated: t.number,
|
|
revoked: t.boolean
|
|
})
|
|
]);
|
|
|
|
const jobStateCodec = t.union([t.type({
|
|
state: t.union([t.literal('submitted'), t.literal('expired'), t.literal('rejected')]),
|
|
job: t.string,
|
|
created: t.number,
|
|
updated: t.number,
|
|
now: t.number
|
|
}), t.type({
|
|
state: t.literal('completed'),
|
|
job: t.string,
|
|
created: t.number,
|
|
updated: t.number,
|
|
result: t.string,
|
|
now: t.number
|
|
}), t.type({
|
|
state: t.literal('empty'),
|
|
now: t.number
|
|
})]);
|
|
|
|
export type TonhubWalletConfig = {
|
|
address: string,
|
|
endpoint: string,
|
|
walletType: string,
|
|
walletConfig: string,
|
|
walletSig: string,
|
|
appPublicKey: string
|
|
}
|
|
|
|
export type TonhubCreatedSession = {
|
|
id: string
|
|
seed: string,
|
|
link: string
|
|
};
|
|
|
|
export type TonhubSessionStateRevoked = {
|
|
state: 'revoked'
|
|
};
|
|
|
|
export type TonhubSessionStateExpired = {
|
|
state: 'expired'
|
|
}
|
|
|
|
export type TonhubSessionStateReady = {
|
|
state: 'ready',
|
|
name: string,
|
|
url: string,
|
|
created: number,
|
|
updated: number,
|
|
wallet: TonhubWalletConfig
|
|
}
|
|
|
|
export type TonhubSessionStateIniting = {
|
|
state: 'initing',
|
|
name: string,
|
|
url: string,
|
|
created: number,
|
|
updated: number
|
|
}
|
|
|
|
export type TonhubSessionState = TonhubSessionStateIniting | TonhubSessionStateRevoked | TonhubSessionStateReady;
|
|
export type TonhubSessionAwaited = TonhubSessionStateRevoked | TonhubSessionStateReady | TonhubSessionStateExpired;
|
|
|
|
export type TonhubTransactionRequest = {
|
|
seed: string,
|
|
appPublicKey: string,
|
|
to: string,
|
|
value: string,
|
|
timeout: number,
|
|
stateInit?: string | null | undefined,
|
|
text?: string | null | undefined,
|
|
payload?: string | null | undefined
|
|
};
|
|
|
|
export type TonhubTransactionResponse = {
|
|
type: 'success',
|
|
response: string
|
|
} | {
|
|
type: 'rejected'
|
|
} | {
|
|
type: 'expired'
|
|
} | {
|
|
type: 'invalid_session'
|
|
};
|
|
|
|
export type TonhubTransactionJob = {
|
|
type: 'success',
|
|
boc: string,
|
|
} | {
|
|
type: 'invalid_session'
|
|
};
|
|
|
|
export type TonhubSignRequest = {
|
|
seed: string,
|
|
appPublicKey: string,
|
|
timeout: number,
|
|
text?: string | null | undefined,
|
|
payload?: string | null | undefined
|
|
}
|
|
|
|
export type TonhubSignResponse = {
|
|
type: 'success',
|
|
signature: string
|
|
} | {
|
|
type: 'rejected'
|
|
} | {
|
|
type: 'expired'
|
|
} | {
|
|
type: 'invalid_session'
|
|
};
|
|
|
|
function idFromSeed(seed: string) {
|
|
let keyPair = keyPairFromSeed(Buffer.from(seed, 'base64'));
|
|
return toUrlSafe(keyPair.publicKey.toString('base64'));
|
|
}
|
|
|
|
function textToCell(src: string) {
|
|
let bytes = Buffer.from(src);
|
|
let res = new Cell();
|
|
let dest = res;
|
|
while (bytes.length > 0) {
|
|
let avaliable = Math.floor(dest.bits.available / 8);
|
|
if (bytes.length <= avaliable) {
|
|
dest.bits.writeBuffer(bytes);
|
|
break;
|
|
}
|
|
dest.bits.writeBuffer(bytes.slice(0, avaliable));
|
|
bytes = bytes.slice(avaliable, bytes.length);
|
|
let nc = new Cell();
|
|
dest.refs.push(nc);
|
|
dest = nc;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
export class TonhubConnector {
|
|
|
|
static verifyWalletConfig(session: string, config: TonhubWalletConfig) {
|
|
|
|
// Check address
|
|
const address = Address.parseFriendly(config.address).address;
|
|
|
|
// Extract public key and address
|
|
let extracted = extractPublicKeyAndAddress(config);
|
|
if (!extracted) {
|
|
return false;
|
|
}
|
|
|
|
// Check address
|
|
if (!extracted.address.equals(address)) {
|
|
return false;
|
|
}
|
|
|
|
let publicKey: Buffer = extracted.publicKey;
|
|
|
|
// Check signature
|
|
let toSign = beginCell()
|
|
.storeCoins(0)
|
|
.storeBuffer(Buffer.from(session, 'base64'))
|
|
.storeAddress(address)
|
|
// Endpoint
|
|
.storeBit(1)
|
|
.storeRef(beginCell()
|
|
.storeBuffer(Buffer.from(config.endpoint))
|
|
.endCell())
|
|
// App Public Key
|
|
.storeRef(beginCell()
|
|
.storeBuffer(Buffer.from(config.appPublicKey, 'base64'))
|
|
.endCell())
|
|
.endCell();
|
|
|
|
// Sign
|
|
return safeSignVerify(toSign, Buffer.from(config.walletSig, 'base64'), publicKey);
|
|
}
|
|
|
|
readonly network: 'mainnet' | 'testnet';
|
|
readonly transport: Transport;
|
|
|
|
|
|
constructor(args?: { network?: 'mainnet' | 'testnet', transport?: Transport }) {
|
|
let network: 'mainnet' | 'testnet' = 'mainnet';
|
|
if (args) {
|
|
if (args.network !== undefined) {
|
|
network = args.network;
|
|
}
|
|
}
|
|
|
|
this.network = network;
|
|
this.transport = args?.transport || new TonhubHttpTransport();
|
|
}
|
|
|
|
createNewSession = async (args: { name: string, url: string }): Promise<TonhubCreatedSession> => {
|
|
|
|
// Generate new key
|
|
let seed = await getSecureRandomBytes(32);
|
|
let keyPair = keyPairFromSeed(seed);
|
|
let sessionId = toUrlSafe(keyPair.publicKey.toString('base64'));
|
|
|
|
// Request new session
|
|
await backoff(async () => {
|
|
let session = await this.transport.call('session_new', {
|
|
key: sessionId,
|
|
testnet: this.network === 'testnet',
|
|
name: args.name,
|
|
url: args.url,
|
|
});
|
|
|
|
|
|
if (!session.ok) {
|
|
throw Error('Unable to create state');
|
|
}
|
|
});
|
|
|
|
// Return session
|
|
return {
|
|
id: sessionId,
|
|
seed: seed.toString('base64'),
|
|
link: (this.network === 'testnet' ? 'ton-test://connect/' : 'ton://connect/') + sessionId + '?endpoint=connect.tonhubapi.com'
|
|
};
|
|
}
|
|
|
|
private ensureSessionStateCorrect = (sessionId: string, ex: any): TonhubSessionState => {
|
|
if (!sessionStateCodec.is(ex)) {
|
|
throw Error('Invalid response from server');
|
|
}
|
|
if (ex.state === 'initing') {
|
|
if (ex.testnet !== (this.network === 'testnet')) {
|
|
return { state: 'revoked' };
|
|
}
|
|
return {
|
|
state: 'initing',
|
|
name: ex.name,
|
|
url: ex.url,
|
|
created: ex.created,
|
|
updated: ex.updated
|
|
};
|
|
}
|
|
if (ex.state === 'ready') {
|
|
if (ex.revoked) {
|
|
return { state: 'revoked' };
|
|
}
|
|
if (ex.testnet !== (this.network === 'testnet')) {
|
|
return { state: 'revoked' };
|
|
}
|
|
if (!TonhubConnector.verifyWalletConfig(sessionId, ex.wallet)) {
|
|
throw Error('Integrity check failed');
|
|
}
|
|
|
|
return {
|
|
state: 'ready',
|
|
name: ex.name,
|
|
url: ex.url,
|
|
created: ex.created,
|
|
updated: ex.updated,
|
|
wallet: {
|
|
address: ex.wallet.address,
|
|
endpoint: ex.wallet.endpoint,
|
|
walletType: ex.wallet.walletType,
|
|
walletConfig: ex.wallet.walletConfig,
|
|
walletSig: ex.wallet.walletSig,
|
|
appPublicKey: ex.wallet.appPublicKey
|
|
}
|
|
};
|
|
}
|
|
|
|
return { state: 'revoked' };
|
|
}
|
|
|
|
getSessionState = async (sessionId: string): Promise<TonhubSessionState> => {
|
|
return await backoff(async () => {
|
|
let session = await this.transport.call('session_get', {
|
|
id: sessionId
|
|
});
|
|
return this.ensureSessionStateCorrect(sessionId, session);
|
|
});
|
|
}
|
|
|
|
waitForSessionState = async (sessionId: string, lastUpdated?: number): Promise<TonhubSessionState> => {
|
|
return await backoff(async () => {
|
|
let session = await this.transport.call('session_wait', {
|
|
id: sessionId,
|
|
lastUpdated
|
|
});
|
|
return this.ensureSessionStateCorrect(sessionId, session);
|
|
})
|
|
}
|
|
|
|
awaitSessionReady = async (sessionId: string, timeout: number, lastUpdated?: number): Promise<TonhubSessionAwaited> => {
|
|
let expires = Date.now() + timeout;
|
|
let res: TonhubSessionStateReady | TonhubSessionStateExpired | TonhubSessionStateRevoked = await backoff(async () => {
|
|
while (Date.now() < expires) {
|
|
let existing = await this.waitForSessionState(sessionId, lastUpdated);
|
|
if (existing.state !== 'initing') {
|
|
if (existing.state === 'ready') {
|
|
return existing;
|
|
} else if (existing.state === 'revoked') {
|
|
return existing;
|
|
}
|
|
}
|
|
await delay(1000);
|
|
}
|
|
return { state: 'expired' };
|
|
});
|
|
return res;
|
|
}
|
|
|
|
requestTransaction = async (request: TonhubTransactionRequest): Promise<TonhubTransactionResponse> => {
|
|
const job = await this.requestTransactionNowait(request);
|
|
if (job.type === 'invalid_session') {
|
|
return { type: 'invalid_session' };
|
|
}
|
|
|
|
// Await result
|
|
let result = await this._awaitJobState(request.appPublicKey, job.boc);
|
|
if (result.type === 'completed') {
|
|
return { type: 'success', response: result.result };
|
|
} else if (result.type === 'rejected') {
|
|
return { type: 'rejected' };
|
|
}
|
|
return { type: 'expired' };
|
|
}
|
|
|
|
requestTransactionNowait = async (request: TonhubTransactionRequest): Promise<TonhubTransactionJob> => {
|
|
const sessionId = idFromSeed(request.seed);
|
|
|
|
// Check session
|
|
let session = await backoff(() => this.getSessionState(sessionId));
|
|
if (session.state !== 'ready') {
|
|
return { type: 'invalid_session' };
|
|
}
|
|
if (session.wallet.appPublicKey !== request.appPublicKey) {
|
|
return { type: 'invalid_session' };
|
|
}
|
|
|
|
// Parse address
|
|
let address = Address.parseFriendly(request.to).address;
|
|
|
|
// Value
|
|
let value = new BN(request.value, 10);
|
|
|
|
// Parse data
|
|
let data: Cell | null = null;
|
|
if (typeof request.payload === 'string') {
|
|
data = Cell.fromBoc(Buffer.from(request.payload, 'base64'))[0];
|
|
}
|
|
|
|
// StateInit
|
|
let stateInit: Cell | null = null;
|
|
if (typeof request.stateInit === 'string') {
|
|
stateInit = Cell.fromBoc(Buffer.from(request.stateInit, 'base64'))[0];
|
|
}
|
|
|
|
// Comment
|
|
let comment: string = '';
|
|
if (typeof request.text === 'string') {
|
|
comment = request.text;
|
|
}
|
|
|
|
// Prepare cell
|
|
let expires = Math.floor((Date.now() + request.timeout) / 1000);
|
|
const job = beginCell()
|
|
.storeBuffer(Buffer.from(session.wallet.appPublicKey, 'base64'))
|
|
.storeUint(expires, 32)
|
|
.storeCoins(0)
|
|
.storeRef(beginCell()
|
|
.storeAddress(address)
|
|
.storeCoins(value)
|
|
.storeRef(textToCell(comment))
|
|
.storeRefMaybe(data ? data : null)
|
|
.storeRefMaybe(stateInit ? stateInit : null)
|
|
.endCell())
|
|
.endCell()
|
|
|
|
// Sign
|
|
let keypair = keyPairFromSeed(Buffer.from(request.seed, 'base64'));
|
|
let signature = safeSign(job, keypair.secretKey);
|
|
|
|
// Create package
|
|
let pkg = beginCell()
|
|
.storeBuffer(signature)
|
|
.storeBuffer(keypair.publicKey)
|
|
.storeRef(job)
|
|
.endCell();
|
|
let boc = pkg.toBoc({ idx: false }).toString('base64');
|
|
|
|
// Post command
|
|
await backoff(() => this.transport.call('command_new', {
|
|
job: boc,
|
|
}));
|
|
|
|
return { type: 'success', boc };
|
|
}
|
|
|
|
requestSign = async (request: TonhubSignRequest): Promise<TonhubSignResponse> => {
|
|
|
|
const sessionId = idFromSeed(request.seed);
|
|
|
|
// Check session
|
|
let session = await backoff(() => this.getSessionState(sessionId));
|
|
if (session.state !== 'ready') {
|
|
return { type: 'invalid_session' };
|
|
}
|
|
if (session.wallet.appPublicKey !== request.appPublicKey) {
|
|
return { type: 'invalid_session' };
|
|
}
|
|
|
|
// Parse data
|
|
let data: Cell = new Cell();
|
|
if (typeof request.payload === 'string') {
|
|
data = Cell.fromBoc(Buffer.from(request.payload, 'base64'))[0];
|
|
}
|
|
|
|
// Comment
|
|
let comment: string = '';
|
|
if (typeof request.text === 'string') {
|
|
comment = request.text;
|
|
}
|
|
|
|
// Prepare cell
|
|
let expires = Math.floor((Date.now() + request.timeout) / 1000);
|
|
let commentCell = new Cell();
|
|
new CommentMessage(comment).writeTo(commentCell);
|
|
const job = beginCell()
|
|
.storeBuffer(Buffer.from(session.wallet.appPublicKey, 'base64'))
|
|
.storeUint(expires, 32)
|
|
.storeCoins(1)
|
|
.storeRef(beginCell()
|
|
.storeRef(commentCell)
|
|
.storeRef(data)
|
|
.endCell())
|
|
.endCell();
|
|
|
|
// Sign
|
|
let keypair = keyPairFromSeed(Buffer.from(request.seed, 'base64'));
|
|
let signature = safeSign(job, keypair.secretKey);
|
|
|
|
// Create package
|
|
let pkg = beginCell()
|
|
.storeBuffer(signature)
|
|
.storeBuffer(keypair.publicKey)
|
|
.storeRef(job)
|
|
.endCell();
|
|
let boc = pkg.toBoc({ idx: false }).toString('base64');
|
|
|
|
|
|
// Post command
|
|
await backoff(() => this.transport.call('command_new', {
|
|
job: boc,
|
|
}));
|
|
|
|
// Await result
|
|
let result = await this._awaitJobState(request.appPublicKey, boc);
|
|
if (result.type === 'completed') {
|
|
const cellRes = Cell.fromBoc(Buffer.from(result.result, 'base64'))[0];
|
|
let slice = cellRes.beginParse();
|
|
const resSignature = slice.readBuffer(64);
|
|
let correct = verifySignatureResponse({
|
|
signature: resSignature.toString('base64'),
|
|
config: session.wallet,
|
|
payload: request.payload,
|
|
text: request.text,
|
|
});
|
|
|
|
if (correct) {
|
|
return { type: 'success', signature: resSignature.toString('base64') };
|
|
} else {
|
|
return { type: 'rejected' };
|
|
}
|
|
} else if (result.type === 'rejected') {
|
|
return { type: 'rejected' };
|
|
}
|
|
return { type: 'expired' };
|
|
}
|
|
|
|
private _awaitJobState = async (appPublicKey: string, boc: string): Promise<{ type: 'completed', result: string } | { type: 'rejected' | 'expired' }> => {
|
|
return await backoff(async (): Promise<{ type: 'completed', result: string } | { type: 'rejected' | 'expired' }> => {
|
|
while (true) {
|
|
let state = await this._getJobState(appPublicKey, boc);
|
|
if (state.type === 'expired') {
|
|
return { type: 'expired' };
|
|
}
|
|
if (state.type === 'completed') {
|
|
return { type: 'completed', result: state.result };
|
|
}
|
|
if (state.type === 'rejected') {
|
|
return { type: 'rejected' };
|
|
}
|
|
await delay(1000);
|
|
}
|
|
});
|
|
}
|
|
|
|
private _getJobState = async (appPublicKey: string, boc: string): Promise<{ type: 'expired' | 'rejected' | 'submitted' } | { type: 'completed', result: string }> => {
|
|
let appk = toUrlSafe(appPublicKey);
|
|
|
|
let res = await this.transport.call('command_get', { appk });
|
|
|
|
if (!jobStateCodec.is(res)) {
|
|
throw Error('Invalid response from server');
|
|
}
|
|
if (res.state === 'empty') {
|
|
return { type: 'expired' };
|
|
}
|
|
if (res.job !== boc) {
|
|
return { type: 'rejected' };
|
|
}
|
|
if (res.state === 'expired') {
|
|
return { type: 'expired' };
|
|
}
|
|
if (res.state === 'submitted') {
|
|
return { type: 'submitted' };
|
|
}
|
|
if (res.state === 'rejected') {
|
|
return { type: 'rejected' };
|
|
}
|
|
if (res.state === 'completed') {
|
|
return { type: 'completed', result: res.result };
|
|
}
|
|
throw Error('Invalid response from server');
|
|
};
|
|
} |