Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ dist
dist-electron
release
*.local

# Allow vendored library dist/ (e.g. vendor/dxs-bsv-token-sdk/dist) so the
# fork is buildable standalone without the parent monorepo checked out.
!vendor/**

docs/superpowers

# Editor directories and files
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Main Process (storage.ts)
↓ StorageManager.callMethod()
StorageKnex
↓ Knex queries
SQLite (~/.bsv-desktop/wallet.db)
SQLite (~/.bsv-desktop/wallet-<identityKeyHex>-main.db)
```

**Remote Storage** (`useRemoteStorage: true`):
Expand Down Expand Up @@ -280,7 +280,7 @@ Worker Process
**Local (Electron)**:
- Modify `electron/storage.ts` for IPC handlers
- Update `src/StorageElectronIPC.ts` for proxy methods
- Database at `~/.bsv-desktop/wallet.db` or `wallet-test.db`
- Database files at `~/.bsv-desktop/wallet-<identityKeyHex>-main.db` (mainnet) or `-test.db` (testnet). One file per identity key; the bare `wallet.db` is legacy and empty on new installs.

**Remote (WAB)**:
- Uses `StorageClient` from `@bsv/wallet-toolbox`
Expand Down Expand Up @@ -339,8 +339,8 @@ curl -X POST http://127.0.0.1:3321/listOutputs \

### Database

- Location: `~/.bsv-desktop/wallet.db` (mainnet) or `wallet-test.db` (testnet)
- WAL mode files: `wallet.db-wal`, `wallet.db-shm`
- Per-identity files: `~/.bsv-desktop/wallet-<identityKeyHex>-main.db` (mainnet) or `-test.db` (testnet). To find your active DB, grep the wallet log for `identityKey: …`, then match the hex prefix.
- WAL mode files: `<wallet-file>-wal`, `<wallet-file>-shm`
- Delete database: `rm -rf ~/.bsv-desktop/` (forces re-initialization)

## Common Patterns
Expand Down
571 changes: 571 additions & 0 deletions MULTI-PROTOCOL-TOKENS.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,19 @@ ipcMain.handle('secrets:delete', async (_event, name: string) => {
vault.deleteSecret(name);
});

// STAS extension query channel — separate from storage:call-method so STAS
// queries do not share the StorageKnex method namespace.
ipcMain.handle('stas:query', async (_event, identityKey: string, chain: 'main' | 'test', method: string, args: any[]) => {
try {
const manager = await getStorageManager();
const result = await manager.callStasQuery(identityKey, chain, method, args ?? []);
return { success: true, result };
} catch (error: any) {
console.error('[IPC] stas:query error:', error);
return { success: false, error: error.message };
}
});

// ===== Auto-Update IPC Handlers =====

ipcMain.handle('update:check', async () => {
Expand Down
9 changes: 9 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
set: (config: any): Promise<void> => ipcRenderer.invoke('boot-config:set', config),
},

// STAS extension queries
stas: {
query: (identityKey: string, chain: 'main' | 'test', method: string, args: any[]) =>
ipcRenderer.invoke('stas:query', identityKey, chain, method, args)
},

// Auto-update operations
updates: {
check: () => ipcRenderer.invoke('update:check'),
Expand Down Expand Up @@ -190,6 +196,9 @@ export interface ElectronAPI {
get: () => Promise<any>;
set: (config: any) => Promise<void>;
};
stas: {
query: (identityKey: string, chain: 'main' | 'test', method: string, args: any[]) => Promise<{ success: boolean; result?: any; error?: string }>;
};
updates: {
check: () => Promise<{ success: boolean; updateInfo?: any; error?: string }>;
download: () => Promise<{ success: boolean; error?: string }>;
Expand Down
74 changes: 74 additions & 0 deletions electron/stas-migrations/0001_create_stas_tables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* STAS extension schema — migration 0001.
*
* Three satellite tables that extend wallet-toolbox's own schema without
* modifying it. They live in the same SQLite database but are created and
* tracked by bsv-desktop's own migration set (tracking table
* `knex_migrations_stas`), so a wallet-toolbox upgrade never touches them.
*
* - stas_tokens one row per token contract (token-level metadata)
* - stas_outputs one row per STAS UTXO; outputId FKs wallet-toolbox's
* `outputs` table, which stays authoritative for the UTXO
* - stas_receive_contexts every derived BRC-42 receive key, recorded at
* derivation time (before funding); MAX(keyIndex) is the
* receive-key high-water mark used for resync
*
* `knex` is typed `any` here: tsconfig.electron has strict:false and this keeps
* the migration free of a direct knex type dependency.
*/

export async function up(knex: any): Promise<void> {
if (!(await knex.schema.hasTable('stas_tokens'))) {
await knex.schema.createTable('stas_tokens', (t: any) => {
t.text('tokenId').primary();
t.text('symbol').notNullable();
t.text('name');
t.integer('satoshisPerToken').notNullable().defaultTo(1);
t.boolean('freezeEnabled').notNullable().defaultTo(false);
t.boolean('confiscationEnabled').notNullable().defaultTo(false);
t.text('redemptionPkh');
t.text('issuerIdentityKey');
t.text('flagsHex');
t.text('createdAt').notNullable();
});
}

if (!(await knex.schema.hasTable('stas_outputs'))) {
await knex.schema.createTable('stas_outputs', (t: any) => {
// outputId is the PK and a FK to wallet-toolbox's `outputs` table —
// wallet-toolbox owns the UTXO row; this is a satellite extension.
t.integer('outputId').primary().references('outputId').inTable('outputs');
t.text('tokenId').notNullable().references('tokenId').inTable('stas_tokens');
t.text('brc42KeyId');
t.text('ownerFieldHash160').notNullable();
t.bigInteger('tokenSatoshis').notNullable();
t.boolean('frozen').notNullable().defaultTo(false);
t.boolean('confiscated').notNullable().defaultTo(false);
t.text('serviceFieldsJson');
t.text('createdAt').notNullable();
t.text('updatedAt').notNullable();
t.index(['tokenId']);
t.index(['ownerFieldHash160']);
});
}

if (!(await knex.schema.hasTable('stas_receive_contexts'))) {
await knex.schema.createTable('stas_receive_contexts', (t: any) => {
t.increments('id').primary();
t.text('profileIdentityKey').notNullable();
t.integer('keyIndex').notNullable();
t.text('keyId').notNullable();
t.text('ownerFieldHash160').notNullable();
t.text('derivedPublicKey').notNullable();
t.text('createdAt').notNullable();
t.unique(['profileIdentityKey', 'keyIndex']);
t.index(['ownerFieldHash160']);
});
}
}

export async function down(knex: any): Promise<void> {
await knex.schema.dropTableIfExists('stas_outputs');
await knex.schema.dropTableIfExists('stas_receive_contexts');
await knex.schema.dropTableIfExists('stas_tokens');
}
154 changes: 154 additions & 0 deletions electron/stas-migrations/0002_add_protocol_column.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* STAS extension schema — migration 0002.
*
* Adds `protocol` to `stas_tokens` and `stas_outputs` so every satellite
* row self-describes which protocol it belongs to ('stas', 'dstas', and
* later 'bsv-21'). Until now, classic STAS and DSTAS UTXOs lived in the
* same satellite tables and the same wallet-toolbox basket; they were
* distinguished only by re-parsing their locking script.
*
* Three steps, all inside one transaction so partial failure leaves the
* database in its pre-migration state:
* 1. ADD COLUMN protocol DEFAULT 'stas' to stas_tokens + stas_outputs.
* 2. Backfill: any stas_outputs row whose joined locking script does
* NOT start with the classic-STAS prefix `76a914…88ac69` is DSTAS.
* Flip its protocol to 'dstas' and stamp the joined token row too.
* 3. Move every DSTAS output to a dedicated `dstas-tokens` basket
* (created lazily per user). Existing STAS outputs stay in
* `stas-tokens`. Future registrations route by protocol via the
* TokenProtocolAdapter layer.
*
* The prefix sniff is the same one StasDiscoveryService.tryParseClassicStasOwner
* uses (`76a914` head, `88ac69` at offset 23-25). It's intentionally
* cheap + dependency-free so the migration doesn't need to load the
* dxs SDK from the Electron main bundle.
*/

const CLASSIC_STAS_PREFIX = '76a914';
const CLASSIC_STAS_ENGINE_MARKER = '88ac69';
const DSTAS_BASKET_NAME = 'dstas-tokens';

function looksLikeClassicStasHex(hex: string): boolean {
return (
typeof hex === 'string' &&
hex.length >= 56 &&
hex.startsWith(CLASSIC_STAS_PREFIX) &&
hex.substring(46, 52) === CLASSIC_STAS_ENGINE_MARKER
);
}

/** Convert a SQLite BLOB (Buffer) or hex string to lowercase hex. */
function toHex(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value.toLowerCase();
if (Buffer.isBuffer(value)) return value.toString('hex');
try {
return Buffer.from(value as any).toString('hex');
} catch {
return '';
}
}

export async function up(knex: any): Promise<void> {
await knex.transaction(async (trx: any) => {
// 1. Add the columns. Knex's alterTable handles SQLite's `ADD COLUMN`.
const hasOutputsProto = await trx.schema.hasColumn('stas_outputs', 'protocol');
if (!hasOutputsProto) {
await trx.schema.alterTable('stas_outputs', (t: any) => {
t.text('protocol').notNullable().defaultTo('stas');
t.index(['protocol']);
});
}
const hasTokensProto = await trx.schema.hasColumn('stas_tokens', 'protocol');
if (!hasTokensProto) {
await trx.schema.alterTable('stas_tokens', (t: any) => {
t.text('protocol').notNullable().defaultTo('stas');
});
}

// 2. Identify DSTAS rows by sniffing each output's locking script.
// Anything in stas_outputs is one of the two protocols by
// construction — if it isn't classic, it must be DSTAS.
const rows: Array<{
outputId: number;
tokenId: string;
lockingScript: unknown;
userId: number;
}> = await trx('stas_outputs as so')
.join('outputs as o', 'o.outputId', 'so.outputId')
.select('so.outputId', 'so.tokenId', 'o.lockingScript', 'o.userId');

const dstasOutputIds: number[] = [];
const dstasTokenIds = new Set<string>();
const dstasUsersToOutputs = new Map<number, number[]>();
for (const row of rows) {
const hex = toHex(row.lockingScript);
if (looksLikeClassicStasHex(hex)) continue;
dstasOutputIds.push(row.outputId);
if (row.tokenId) dstasTokenIds.add(row.tokenId);
const list = dstasUsersToOutputs.get(row.userId) ?? [];
list.push(row.outputId);
dstasUsersToOutputs.set(row.userId, list);
}

if (dstasOutputIds.length > 0) {
// Stamp protocol = 'dstas' on every identified satellite row.
await trx('stas_outputs')
.whereIn('outputId', dstasOutputIds)
.update({ protocol: 'dstas' });
}
if (dstasTokenIds.size > 0) {
await trx('stas_tokens')
.whereIn('tokenId', [...dstasTokenIds])
.update({ protocol: 'dstas' });
}

// 3. Move DSTAS outputs into a `dstas-tokens` basket per user. The
// wallet-toolbox baskets table is keyed unique on (name, userId),
// so per-user provisioning is the safe shape even on databases
// that only ever held one user.
if (dstasUsersToOutputs.size > 0) {
const now = new Date().toISOString();
for (const [userId, outputIds] of dstasUsersToOutputs) {
let basket: { basketId: number } | undefined = await trx('output_baskets')
.where({ name: DSTAS_BASKET_NAME, userId })
.first('basketId');
if (!basket) {
const [basketId] = await trx('output_baskets').insert({
userId,
name: DSTAS_BASKET_NAME,
// Match the STAS basket's profile: 0 desired UTXOs (no
// change fragmentation), 10000 sat minimum (the toolbox
// default — STAS satoshisPerToken is 1 so this is moot).
numberOfDesiredUTXOs: 0,
minimumDesiredUTXOValue: 10000,
isDeleted: 0,
created_at: now,
updated_at: now,
});
basket = { basketId };
}
await trx('outputs')
.whereIn('outputId', outputIds)
.update({ basketId: basket.basketId, updated_at: now });
}
}
});
}

/**
* Down migration is best-effort. SQLite 3.35+ supports `ALTER TABLE
* DROP COLUMN`; older builds would need a table rebuild. We don't
* attempt to reverse the basket move — that would require remembering
* which outputs we relocated, and the forward migration is idempotent
* enough that running `up()` again is safe.
*/
export async function down(knex: any): Promise<void> {
await knex.schema.alterTable('stas_outputs', (t: any) => {
t.dropIndex(['protocol']);
t.dropColumn('protocol');
});
await knex.schema.alterTable('stas_tokens', (t: any) => {
t.dropColumn('protocol');
});
}
32 changes: 32 additions & 0 deletions electron/stas-migrations/0003_bsv21_receive_contexts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* STAS extension schema — migration 0003.
*
* Creates `bsv21_receive_contexts`, a per-protocol receive-key ledger that
* mirrors `stas_receive_contexts`. Each row records a BRC-42-derived owner
* key (under the BSV-21 protocolID namespace) so a resync can regenerate
* every receive address from just the persisted high-water mark.
*
* No `bsv21_outputs` or `bsv21_tokens` table — BSV-21 token metadata
* (id, amt, dec, sym, icon) lives on wallet-toolbox basket tags by the
* 1sat-wallet-toolbox convention, so the satellite stays receive-only.
*/

export async function up(knex: any): Promise<void> {
if (!(await knex.schema.hasTable('bsv21_receive_contexts'))) {
await knex.schema.createTable('bsv21_receive_contexts', (t: any) => {
t.increments('id').primary();
t.text('profileIdentityKey').notNullable();
t.integer('keyIndex').notNullable();
t.text('keyId').notNullable();
t.text('ownerFieldHash160').notNullable();
t.text('derivedPublicKey').notNullable();
t.text('createdAt').notNullable();
t.unique(['profileIdentityKey', 'keyIndex']);
t.index(['ownerFieldHash160']);
});
}
}

export async function down(knex: any): Promise<void> {
await knex.schema.dropTableIfExists('bsv21_receive_contexts');
}
41 changes: 41 additions & 0 deletions electron/stas-migrations/0004_token_verifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* STAS extension schema — migration 0004.
*
* Creates `token_verifications`, a per-outpoint Back-to-Genesis provenance
* cache. One row per token UTXO the wallet has verified, keyed on the outpoint
* `(txid, vout)`. It is deliberately standard-agnostic — STAS, DSTAS and BSV-21
* all share it — because BSV-21 has no satellite holdings table, and an
* outpoint's provenance is the same question regardless of token standard.
*
* Durability is the whole point: a verdict for a fixed outpoint is immutable
* (a reorg aside), so persisting it here means a re-opened wallet — or a fresh
* install restoring the same DB — shows Verified / Counterfeit badges instantly
* without re-walking the chain. The renderer's in-memory cache is only a
* same-session accelerator on top of this.
*
* Only SETTLED verdicts (`authentic` / `not-authentic`) are stored;
* `undetermined` means "couldn't decide yet" and must be retried, never frozen.
* `genesis` is the resolved `<txid>_<vout>` — the sole spoof-proof token
* identity for classic STAS (whose tokenId is merely the issuer PKH).
*/

export async function up(knex: any): Promise<void> {
if (!(await knex.schema.hasTable('token_verifications'))) {
await knex.schema.createTable('token_verifications', (t: any) => {
t.text('txid').notNullable();
t.integer('vout').notNullable();
t.text('protocol').notNullable(); // 'stas' | 'dstas' | 'bsv-21'
t.text('result').notNullable(); // 'authentic' | 'not-authentic'
t.text('genesis'); // '<txid>_<vout>' of the resolved genesis; null if none
t.integer('genesisDepth');
t.text('reason'); // set when not-authentic
t.text('verifiedAt').notNullable();
t.primary(['txid', 'vout']);
t.index(['genesis']);
});
}
}

export async function down(knex: any): Promise<void> {
await knex.schema.dropTableIfExists('token_verifications');
}
Loading
Loading