Skip to content

Commit

Permalink
feat(datastore): enable credits
Browse files Browse the repository at this point in the history
fix(datastore): enable update and delete queries
  • Loading branch information
blakebyrnes committed Jan 14, 2023
1 parent e844ebe commit 972029e
Show file tree
Hide file tree
Showing 45 changed files with 783 additions and 570 deletions.
1 change: 1 addition & 0 deletions datastore/client/cli/cloneDatastore.ts
Expand Up @@ -4,6 +4,7 @@ import { writeFileSync } from 'fs';
import * as Path from 'path';
import jsonToSchemaCode from '@ulixee/schema/lib/jsonToSchemaCode';
import DatastoreApiClient from '../lib/DatastoreApiClient';
import CreditsTable from '../lib/CreditsTable';

export default async function cloneDatastore(
url: string,
Expand Down
47 changes: 47 additions & 0 deletions datastore/client/cli/creditsCli.ts
@@ -0,0 +1,47 @@
import { Command } from 'commander';
import ArgonUtils from '@ulixee/sidechain/lib/ArgonUtils';
import DatastoreApiClient from '../lib/DatastoreApiClient';
import CreditsStore from '../lib/CreditsStore';

export default function creditsCli(): Command {
const cli = new Command('credits');
cli
.command('create')
.description('Create Credits for a User to try out your Datastore.')
.argument('<url>', 'The url to the Datastore.')
.requiredOption(
'-m, --amount <value>',
'The value of this Credit. Amount can postfix "c" for centagons (eg, 50c) or "m" for microgons (5000000m).',
/\d+[mc]?/,
)
.requiredOption('-i, --identity <idBech32String>', 'You administration identity keypair.')
.action(async (url, { identity, amount }) => {
const parsedUrl = new URL(url);
const client = new DatastoreApiClient(parsedUrl.origin);
const microgons = ArgonUtils.parseUnits(amount, 'microgons');
const result = await client.createCredits(parsedUrl.pathname, microgons, identity);

// TODO: output a url to send the user to
console.log(`Credit created!`, { credit: result });
});

cli
.command('install')
.description('Save to a local wallet.')
.argument('<url>', 'The url of the Credit.')
.argument('<secret>', 'The Credit secret.')
.action(async (url, secret) => {
const parsedUrl = new URL(url);
const client = new DatastoreApiClient(parsedUrl.origin);
const [datastoreVersion, id] = parsedUrl.pathname.split('/credit/');
const { balance } = await client.getCreditsBalance(datastoreVersion, id);
await CreditsStore.store(parsedUrl.origin, datastoreVersion.replace(/\//g, ''), {
id,
secret,
remainingCredits: balance,
});
});

cli.command('get').description('Get the current balance and holds.');
return cli;
}
118 changes: 0 additions & 118 deletions datastore/client/cli/giftCardCommands.ts

This file was deleted.

4 changes: 2 additions & 2 deletions datastore/client/cli/index.ts
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander';
import type * as CliCommands from '@ulixee/datastore-packager/lib/cliCommands';
import UlixeeHostsConfig from '@ulixee/commons/config/hosts';
import DatastoreApiClient from '../lib/DatastoreApiClient';
import giftCardCommands from './giftCardCommands';
import creditsCli from './creditsCli';
import cloneDatastore from './cloneDatastore';

const { version } = require('../package.json');
Expand Down Expand Up @@ -186,7 +186,7 @@ export default function datastoreCommands(): Command {
},
);

cli.addCommand(giftCardCommands());
cli.addCommand(creditsCli());
return cli;
}

Expand Down
5 changes: 3 additions & 2 deletions datastore/client/interfaces/IDatastoreComponents.ts
@@ -1,6 +1,7 @@
import Crawler from '../lib/Crawler';
import Function from '../lib/Function';
import Table from '../lib/Table';
import CreditsTable from '../lib/CreditsTable';

export default interface IDatastoreComponents<
TTable extends TTables<any>,
Expand All @@ -10,11 +11,11 @@ export default interface IDatastoreComponents<
name?: string;
description?: string;
remoteDatastores?: { [source: string]: string };
tables?: TTable;
tables?: TTable & { credits?: CreditsTable };
functions?: TFunction;
crawlers?: TCrawler;
paymentAddress?: string;
giftCardIssuerIdentity?: string;
adminIdentities?: string[];
authenticateIdentity?(identity: string, nonce: string): Promise<boolean> | boolean;
}

Expand Down
12 changes: 6 additions & 6 deletions datastore/client/interfaces/IDatastoreMetadata.ts
@@ -1,13 +1,13 @@
import IFunctionComponents from './IFunctionComponents';
import ITableComponents from './ITableComponents';
import IDatastoreComponents from './IDatastoreComponents';

export default interface IDatastoreMetadata {
name?: string;
description?: string;
export default interface IDatastoreMetadata
extends Omit<
IDatastoreComponents<any, any, any>,
'authenticateIdentity' | 'crawlers' | 'functions' | 'tables'
> {
coreVersion: string;
remoteDatastores?: Record<string, string>;
paymentAddress?: string;
giftCardIssuerIdentity?: string;
functionsByName: {
[name: string]: {
corePlugins: { [name: string]: string };
Expand Down
66 changes: 66 additions & 0 deletions datastore/client/lib/CreditsStore.ts
@@ -0,0 +1,66 @@
import { readFileAsJson, safeOverwriteFile } from '@ulixee/commons/lib/fileUtils';
import { getCacheDirectory } from '@ulixee/commons/lib/dirUtils';
import { IPayment } from '@ulixee/specification';

export default class CreditsStore {
public static storePath = `${getCacheDirectory()}/ulixee/credits.json`;

private static creditsByDatastore: Promise<ICreditsStore>;

public static async store(
minerHost: string,
datastoreVersionHash: string,
credits: { id: string; secret: string; remainingCredits: number },
): Promise<void> {
const allCredits = await this.load();
allCredits[`${minerHost}/${datastoreVersionHash}`] ??= {};
allCredits[`${minerHost}/${datastoreVersionHash}`][credits.id] = credits;
await this.writeToDisk(allCredits);
}

public static async getPayment(
minerHost: string,
datastoreVersionHash: string,
microgons: number,
): Promise<
(IPayment & { onFinalized(result: { microgons: number; bytes: number }): void }) | undefined
> {
const credits = await this.load();
const datastoreCredits = credits[`${minerHost}/${datastoreVersionHash}`];
if (!datastoreCredits) return;

for (const [creditId, credit] of Object.entries(datastoreCredits)) {
if (credit.remainingCredits >= microgons) {
credit.remainingCredits -= microgons;
return {
credits: { id: creditId, secret: credit.secret },
onFinalized: this.finalizePayment.bind(this, credit, microgons),
};
}
}
}

protected static finalizePayment(
originalMicrogons: number,
credits: ICreditsStore[0][0],
result: { microgons: number; bytes: number },
): void {
if (!result) return;
const fundsToReturn = originalMicrogons - result.microgons;
if (fundsToReturn && Number.isInteger(fundsToReturn)) {
credits.remainingCredits += fundsToReturn;
}
}

private static async load(): Promise<ICreditsStore> {
this.creditsByDatastore ??= readFileAsJson<ICreditsStore>(this.storePath).catch(() => ({}));
return await this.creditsByDatastore;
}

private static writeToDisk(data: any): Promise<void> {
return safeOverwriteFile(this.storePath, data);
}
}
interface ICreditsStore {
[versionHost: string]: { [creditsId: string]: { secret: string; remainingCredits: number } };
}
84 changes: 84 additions & 0 deletions datastore/client/lib/CreditsTable.ts
@@ -0,0 +1,84 @@
import { buffer, ExtractSchemaType, number, string } from '@ulixee/schema';
import { sha3 } from '@ulixee/commons/lib/hashUtils';
import { concatAsBuffer } from '@ulixee/commons/lib/bufferUtils';
import { customAlphabet, nanoid } from 'nanoid';
import Table from './Table';

const postgresFriendlyNanoid = customAlphabet(
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz',
);

export default class CreditsTable extends Table<typeof CreditsSchema> {
public static tableName = 'ulx_credits';
constructor() {
super({
name: CreditsTable.tableName,
isPublic: false,
description: 'Private table tracking Credits issued for the containing Datastore.',
schema: CreditsSchema,
pricePerQuery: 0,
});
}

async create(
microgons: number,
secret?: string,
): Promise<{ id: string; secret: string; remainingCredits: number }> {
const salt = nanoid(16);
const id = `cred${postgresFriendlyNanoid(8)}`;
secret ??= postgresFriendlyNanoid(12);
const secretHash = sha3(concatAsBuffer(id, salt, secret));
await this.query(
'INSERT INTO self (id, salt, secretHash, issuedCredits, heldCredits, remainingCredits) VALUES ($1, $2, $3, $4, 0, $4)',
[id, salt, secretHash, microgons],
);
return { id, secret, remainingCredits: microgons };
}

async get(id: string): Promise<Omit<ICredit, 'salt' | 'secretHash'>> {
const [credit] = await this.query(
'SELECT id, issuedCredits, remainingCredits, heldCredits FROM self WHERE id = $1',
[id],
);
return credit;
}

async hold(id: string, secret: string, holdAmount: number): Promise<number> {
const [credit] = await this.query('SELECT * FROM self WHERE id=$1', [id]);
if (!credit) throw new Error('This is an invalid Credit.');

const hash = sha3(concatAsBuffer(credit.id, credit.salt, secret));
if (!hash.equals(credit.secretHash)) throw new Error('This is an invalid Credit secret.');

const result = (await this.query(
'UPDATE self SET heldCredits = heldCredits + $2 ' +
'WHERE id = $1 AND (remainingCredits - heldCredits - $2) >= 0 ' +
'RETURNING remainingCredits',
[id, holdAmount],
)) as any;

if (result === undefined) throw new Error('Could not hold funds from the given Credits.');
return result.remainingCredits;
}

async finalize(id: string, holdAmount: number, finalAmount: number): Promise<number> {
const result = (await this.query(
'UPDATE self SET heldCredits = heldCredits + $2, remainingCredits = remainingCredits - $3 ' +
'WHERE id = $1 ' +
'RETURNING remainingCredits',
[id, holdAmount, finalAmount],
)) as any;
if (result === undefined) throw new Error('Could not finalize payment for the given Credits.');
return result.remainingCredits;
}
}

export const CreditsSchema = {
id: string({ regexp: /^cred[A-Za-z0-9_-]{8}$/ }),
salt: string({ length: 16 }),
secretHash: buffer(),
issuedCredits: number(),
heldCredits: number(),
remainingCredits: number(),
};
type ICredit = ExtractSchemaType<typeof CreditsSchema>;

0 comments on commit 972029e

Please sign in to comment.