Brings the whole project into git (previously only the on-ramp feature and a few parallel files were tracked). Includes: wallet core (src/), CashScript contracts/, the Capacitor shell (shells/), scripts/, tests, and the docs corpus (wiki/, snapshots/, Parameters/, Archive/, KnowledgeBase text). Excluded via .gitignore: node_modules/, dist/, .env (secrets), testnet WIF key files (.chipnet-*.json, devWallets.ts), KnowledgeBase/RAW media (~9.6 GB), transcripts/ (session logs), and machine-local config (.claude/*.local, *.lock). Secret-scanned before commit — no credentials in the tree. Note: src/swapMath.ts, src/swapBuilder.ts, contracts/OpenMintToken.cash are legacy (OP_RETURN era) but still imported; committed as-is, flagged for a separate removal once de-referenced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
8 KiB
TypeScript
213 lines
8 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { jsonSafe, handleBalance, handleCommand, handleRegister, handleTokens, handleFavorite, handleForget } from '../../../src/web/api.js';
|
|
import { InMemoryTokenRegistry } from '../../../src/token/registry.js';
|
|
import type { TokenTierA } from '../../../src/token/types.js';
|
|
import type { DeviantWallet } from '../../../src/wallet/wallet.js';
|
|
import type { RunContext } from '../../../src/cli/wallet-cli.js';
|
|
|
|
const CAT_A = 'aa'.repeat(32);
|
|
|
|
function tokenA(over: Partial<TokenTierA> = {}): TokenTierA {
|
|
const id = over.id ?? CAT_A;
|
|
return {
|
|
id, tier: 'A', name: 'ASM', ticker: 'ASM', rateSats: 100n,
|
|
createdAt: 1, lastSeen: 1, userVerified: false, pinned: false, hidden: false,
|
|
categoryId: id, founderFeeBps: 100,
|
|
founderLockingBytecodeHex: '76a914' + '11'.repeat(20) + '88ac',
|
|
founderAddress: 'bchtest:qrf', contractAddress: 'bchtest:pc', contractTokenAddress: 'bchtest:rc',
|
|
contractBytecodeHash: '99'.repeat(32), initialReservoirSupply: '1', genesisTxId: '11'.repeat(32),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function mockWallet(record: { calls: string[] }): DeviantWallet {
|
|
return {
|
|
async getBalance() {
|
|
record.calls.push('getBalance');
|
|
return {
|
|
bchSats: 12345n,
|
|
tokens: [
|
|
{ kind: 'tier_a', token: tokenA(), verification: 'canonical', unitsRedeemable: 50n,
|
|
unitsUnderbacked: 0n, assetClassLabel: 'DEA', warnings: [] },
|
|
],
|
|
unknownTokens: [],
|
|
} as any;
|
|
},
|
|
async planClaim(intent: any) {
|
|
record.calls.push(`planClaim:${intent.units}`);
|
|
return { confirmationMessage: 'CLAIM PLAN', warnings: [] } as any;
|
|
},
|
|
async executeClaim() {
|
|
record.calls.push('executeClaim');
|
|
return { txid: 'cc'.repeat(32) };
|
|
},
|
|
} as unknown as DeviantWallet;
|
|
}
|
|
|
|
function ctx(record: { calls: string[] }): RunContext {
|
|
const registry = new InMemoryTokenRegistry();
|
|
registry.add(tokenA());
|
|
return { wallet: mockWallet(record), registry, ownTokenAddress: 'bchtest:rself' };
|
|
}
|
|
|
|
// ---- jsonSafe ----
|
|
|
|
test('jsonSafe: stringifies bigints recursively (objects, arrays, maps)', () => {
|
|
const input = {
|
|
a: 10n,
|
|
b: [1n, 2n, 'x'],
|
|
c: { d: 3n, e: false },
|
|
m: new Map<string, bigint>([['k', 9n]]),
|
|
};
|
|
const out = jsonSafe(input) as any;
|
|
assert.equal(out.a, '10');
|
|
assert.deepEqual(out.b, ['1', '2', 'x']);
|
|
assert.equal(out.c.d, '3');
|
|
assert.equal(out.c.e, false);
|
|
assert.equal(out.m.k, '9');
|
|
// Round-trips through JSON without throwing.
|
|
assert.doesNotThrow(() => JSON.stringify(out));
|
|
});
|
|
|
|
// ---- handleBalance ----
|
|
|
|
test('handleBalance: returns JSON-safe structured balance', async () => {
|
|
const record = { calls: [] as string[] };
|
|
const balance = (await handleBalance(ctx(record))) as any;
|
|
assert.equal(balance.bchSats, '12345');
|
|
assert.equal(balance.tokens[0].ticker ?? balance.tokens[0].token.ticker, 'ASM');
|
|
assert.equal(balance.tokens[0].unitsRedeemable, '50');
|
|
assert.equal(balance.tokens[0].verification, 'canonical');
|
|
assert.doesNotThrow(() => JSON.stringify(balance));
|
|
assert.deepEqual(record.calls, ['getBalance']);
|
|
});
|
|
|
|
// ---- handleCommand ----
|
|
|
|
test('handleCommand: previews claim (no broadcast) when yes flag absent', async () => {
|
|
const record = { calls: [] as string[] };
|
|
const res = await handleCommand(ctx(record), { command: 'claim', args: ['ASM', '1000'] });
|
|
assert.equal(res.previewOnly, true);
|
|
assert.equal(res.txid, undefined);
|
|
assert.match(res.output, /CLAIM PLAN/);
|
|
assert.ok(record.calls.includes('planClaim:1000'));
|
|
assert.ok(!record.calls.includes('executeClaim'));
|
|
});
|
|
|
|
test('handleCommand: broadcasts when yes flag set', async () => {
|
|
const record = { calls: [] as string[] };
|
|
const res = await handleCommand(ctx(record), {
|
|
command: 'claim',
|
|
args: ['ASM', '1000'],
|
|
flags: { yes: true },
|
|
});
|
|
assert.equal(res.previewOnly, false);
|
|
assert.equal(res.txid, 'cc'.repeat(32));
|
|
assert.ok(record.calls.includes('executeClaim'));
|
|
});
|
|
|
|
test('handleCommand: throws when command missing', async () => {
|
|
await assert.rejects(() => handleCommand(ctx({ calls: [] }), {} as any), /command is required/);
|
|
});
|
|
|
|
// ---- handleRegister ----
|
|
|
|
const portableTierB = {
|
|
version: 1, id: 'tb-web-1', tier: 'B', name: 'WebChips', ticker: 'WCHIP',
|
|
rateSats: '500', createdAt: 1, lastSeen: 1,
|
|
userVerified: false, pinned: false, hidden: false,
|
|
associatedAddresses: ['bchtest:qweb1'],
|
|
};
|
|
|
|
test('handleRegister: adds a token (object body) and persists it', async () => {
|
|
const c = ctx({ calls: [] });
|
|
const persisted: string[] = [];
|
|
const res = (await handleRegister(c, { definition: portableTierB }, (t) => persisted.push(t.id))) as any;
|
|
assert.equal(res.tier, 'B');
|
|
assert.equal(res.action, 'added');
|
|
assert.ok(res.confirmation, 'tier-B carries the D-5 confirmation');
|
|
assert.deepEqual(persisted, ['tb-web-1']);
|
|
assert.ok(c.registry.get('tb-web-1'), 'token is in the registry');
|
|
assert.doesNotThrow(() => JSON.stringify(res)); // bigints stringified
|
|
});
|
|
|
|
test('handleRegister: accepts a JSON string body', async () => {
|
|
const c = ctx({ calls: [] });
|
|
const res = (await handleRegister(c, { definition: JSON.stringify(portableTierB) })) as any;
|
|
assert.equal(res.token.id, 'tb-web-1');
|
|
});
|
|
|
|
test('handleRegister: rejects missing definition', async () => {
|
|
await assert.rejects(() => handleRegister(ctx({ calls: [] }), {} as any), /definition is required/);
|
|
});
|
|
|
|
test('handleRegister: rejects invalid JSON string', async () => {
|
|
await assert.rejects(
|
|
() => handleRegister(ctx({ calls: [] }), { definition: '{not json' }),
|
|
/not valid JSON/,
|
|
);
|
|
});
|
|
|
|
// ---- handleTokens (ASM catalog) ----
|
|
|
|
test('handleTokens: catalog entry per registry token, with rate + classification', () => {
|
|
const cat = handleTokens(ctx({ calls: [] })) as any[];
|
|
assert.ok(Array.isArray(cat));
|
|
const asm = cat.find((t) => t.id === CAT_A);
|
|
assert.ok(asm, 'registered token appears in the catalog');
|
|
assert.equal(asm.ticker, 'ASM');
|
|
assert.equal(asm.tier, 'A');
|
|
assert.equal(asm.rateSats, '100'); // bigint stringified
|
|
assert.equal(typeof asm.verification, 'string');
|
|
// Placeholder contract address can't be canonical → not ASM-eligible, no class.
|
|
assert.equal(asm.asm, 'none');
|
|
assert.equal(asm.assetClass, null);
|
|
assert.doesNotThrow(() => JSON.stringify(cat));
|
|
});
|
|
|
|
// ---- handleFavorite ----
|
|
|
|
test('handleFavorite: pins a token, persists, and reflects in the registry', () => {
|
|
const c = ctx({ calls: [] });
|
|
const persisted: string[] = [];
|
|
const res = handleFavorite(c, { id: CAT_A, pinned: true }, (t) => persisted.push(t.id)) as any;
|
|
assert.deepEqual(res, { id: CAT_A, pinned: true });
|
|
assert.equal(c.registry.get(CAT_A)!.pinned, true);
|
|
assert.deepEqual(persisted, [CAT_A]);
|
|
});
|
|
|
|
test('handleFavorite: unpins (pinned=false)', () => {
|
|
const c = ctx({ calls: [] });
|
|
handleFavorite(c, { id: CAT_A, pinned: true });
|
|
const res = handleFavorite(c, { id: CAT_A, pinned: false }) as any;
|
|
assert.equal(res.pinned, false);
|
|
assert.equal(c.registry.get(CAT_A)!.pinned, false);
|
|
});
|
|
|
|
test('handleFavorite: rejects missing id', () => {
|
|
assert.throws(() => handleFavorite(ctx({ calls: [] }), {} as any), /id is required/);
|
|
});
|
|
|
|
// ---- handleForget ("Convert to BCH") ----
|
|
|
|
test('handleForget: removes the token and drops its persisted file', () => {
|
|
const c = ctx({ calls: [] });
|
|
const dropped: string[] = [];
|
|
const res = handleForget(c, { id: CAT_A }, (id) => dropped.push(id)) as any;
|
|
assert.deepEqual(res, { id: CAT_A, removed: true });
|
|
assert.equal(c.registry.get(CAT_A), undefined);
|
|
assert.deepEqual(dropped, [CAT_A]);
|
|
});
|
|
|
|
test('handleForget: rejects missing id', () => {
|
|
assert.throws(() => handleForget(ctx({ calls: [] }), {} as any), /id is required/);
|
|
});
|
|
|
|
test('handleForget: throws when the id is not registered', () => {
|
|
assert.throws(() => handleForget(ctx({ calls: [] }), { id: 'nope' }), /not found/);
|
|
});
|
|
|
|
test('handleFavorite: propagates unknown-id error from the registry', () => {
|
|
assert.throws(() => handleFavorite(ctx({ calls: [] }), { id: 'nope', pinned: true }), /not found/);
|
|
});
|