Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions etc/sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export interface BaseWalletAdapter<TChain extends WalletAdapterChain, TSignature
}

// @public
export interface ChainScannerAdapter<TItem = any, TKeys = any, TMatched = any, TMetaAddress = any> {
export interface ChainScannerAdapter<TItem = unknown, TKeys = unknown, TMatched = unknown, TMetaAddress = unknown> {
decodeMetaAddress(metaAddress: string): TMetaAddress;
encodeMetaAddress(spendingPubKey: any, viewingPubKey: any): string;
encodeMetaAddress(spendingPubKey: unknown, viewingPubKey: unknown): string;
id: string;
scan(source: AsyncIterable<TItem>, keys: TKeys): AsyncGenerator<TMatched>;
timestampOf?(matched: TMatched): number | undefined;
Expand Down Expand Up @@ -64,8 +64,8 @@ export function createSolanaWalletAdapter(wallet: SolanaWalletAdapterLike): Sola
export function createViemWalletAdapter(client: ViemWalletClient): ViemWalletAdapter;

// @public
export interface CustomChainInput<TItem = any, TKeys = any, TMatched = any> {
adapter: ChainScannerAdapter<TItem, TKeys, TMatched, any>;
export interface CustomChainInput<TItem = unknown, TKeys = unknown, TMatched = unknown, TMetaAddress = unknown> {
adapter: ChainScannerAdapter<TItem, TKeys, TMatched, TMetaAddress>;
keys: TKeys;
source: AsyncIterable<TItem>;
}
Expand Down Expand Up @@ -266,10 +266,11 @@ export type MatchedAnnouncement = {
seq: number;
announcement: MatchedStealthCell;
} | {
chain: string;
chain: 'custom';
customChainId: string;
timestamp: number;
seq: number;
announcement: any;
announcement: unknown;
};

// @public (undocumented)
Expand Down Expand Up @@ -331,7 +332,7 @@ export function scanAll(input: ScanAllInput): AsyncGenerator<MatchedAnnouncement
// @public (undocumented)
export interface ScanAllInput {
// (undocumented)
adapters?: Array<CustomChainInput<any, any, any> | any>;
adapters?: Array<CustomChainInput<unknown, unknown, unknown, unknown>>;
// (undocumented)
ckb?: CkbChainInput;
// (undocumented)
Expand Down Expand Up @@ -636,10 +637,10 @@ export abstract class WraithWalletError extends WraithError {

// Warnings were encountered during analysis:
//
// dist/unified-CssjVK0G.d.ts:166:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts
// dist/unified-CssjVK0G.d.ts:171:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts
// dist/unified-CssjVK0G.d.ts:176:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts
// dist/unified-CssjVK0G.d.ts:181:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts
// dist/unified-DZ7PcCQN.d.ts:166:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts
// dist/unified-DZ7PcCQN.d.ts:171:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts
// dist/unified-DZ7PcCQN.d.ts:176:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts
// dist/unified-DZ7PcCQN.d.ts:181:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"docs": "typedoc",
"test:exports": "node test/smoke/run.mjs",
"test:watch": "vitest",
"test:types": "tsc --noEmit --project tsconfig.test-types.json",
"bench": "vitest bench --run",
"bench:watch": "vitest bench",
"clean": "rm -rf dist",
Expand Down
77 changes: 49 additions & 28 deletions src/scanner/unified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ export const UNKNOWN_TIMESTAMP = 0;
* @template TMatched - Matched announcement output type.
* @template TMetaAddress - Decoded stealth meta-address representation.
*/
export interface ChainScannerAdapter<TItem = any, TKeys = any, TMatched = any, TMetaAddress = any> {
export interface ChainScannerAdapter<
TItem = unknown,
TKeys = unknown,
TMatched = unknown,
TMetaAddress = unknown,
> {
/** Unique string identifier for the chain adapter (e.g., 'evm', 'stellar', 'monero'). */
id: string;

Expand All @@ -69,7 +74,7 @@ export interface ChainScannerAdapter<TItem = any, TKeys = any, TMatched = any, T
* @param spendingPubKey - Public key used for spending derivation.
* @param viewingPubKey - Public key used for viewing/ECDH derivation.
*/
encodeMetaAddress(spendingPubKey: any, viewingPubKey: any): string;
encodeMetaAddress(spendingPubKey: unknown, viewingPubKey: unknown): string;

/**
* Returns the chain time of a matched result, in whole seconds since the Unix
Expand Down Expand Up @@ -97,9 +102,14 @@ export interface ChainScannerAdapter<TItem = any, TKeys = any, TMatched = any, T
/**
* Input configuration for a custom third-party chain scanner adapter.
*/
export interface CustomChainInput<TItem = any, TKeys = any, TMatched = any> {
export interface CustomChainInput<
TItem = unknown,
TKeys = unknown,
TMatched = unknown,
TMetaAddress = unknown,
> {
/** Chain scanner adapter instance. */
adapter: ChainScannerAdapter<TItem, TKeys, TMatched, any>;
adapter: ChainScannerAdapter<TItem, TKeys, TMatched, TMetaAddress>;
/** Async iterable stream of raw announcements/cells. */
source: AsyncIterable<TItem>;
/** Recipient key material required for scanning. */
Expand Down Expand Up @@ -139,7 +149,7 @@ export interface ScanAllInput {
stellar?: StellarChainInput;
solana?: SolanaChainInput;
ckb?: CkbChainInput;
adapters?: Array<CustomChainInput<any, any, any> | any>;
adapters?: Array<CustomChainInput<unknown, unknown, unknown, unknown>>;
}

export type MatchedAnnouncement =
Expand Down Expand Up @@ -168,10 +178,12 @@ export type MatchedAnnouncement =
announcement: CkbMatchedCell;
}
| {
chain: string;
chain: 'custom';
/** The custom adapter's own `id`, since `chain` is fixed to the literal 'custom'. */
customChainId: string;
timestamp: number;
seq: number;
announcement: any;
announcement: unknown;
};

/**
Expand All @@ -193,7 +205,7 @@ function coerceTimestamp(value: unknown): number | undefined {
* must not abort a scan that is otherwise fine, so it degrades to the fallback.
*/
function resolveTimestamp(
adapter: ChainScannerAdapter<any, any, any, any>,
adapter: ChainScannerAdapter<unknown, unknown, unknown, unknown>,
matched: unknown,
): number {
if (typeof adapter.timestampOf === 'function') {
Expand All @@ -215,10 +227,10 @@ function resolveTimestamp(
return UNKNOWN_TIMESTAMP;
}

async function* scanChainAdapterSource(
adapter: ChainScannerAdapter<any, any, any, any>,
source: AsyncIterable<any>,
keys: any,
async function* scanChainAdapterSource<TItem, TKeys, TMatched>(
adapter: ChainScannerAdapter<TItem, TKeys, TMatched, unknown>,
source: AsyncIterable<TItem>,
keys: TKeys,
): AsyncGenerator<{ announcement: unknown; timestamp: number }> {
const stream = adapter.scan(source, keys);
const it = stream[Symbol.asyncIterator]();
Expand Down Expand Up @@ -285,16 +297,10 @@ export async function* scanAll(input: ScanAllInput): AsyncGenerator<MatchedAnnou

if (input.adapters && Array.isArray(input.adapters)) {
for (const item of input.adapters) {
const adapter: ChainScannerAdapter | undefined =
item.adapter ?? (item.id && item.scan ? item : undefined);
const source = item.source ?? item.input?.source;
const keys = item.keys ?? item.input?.keys;
if (adapter && source) {
tasks.push({
id: adapter.id,
gen: scanChainAdapterSource(adapter, source, keys),
});
}
tasks.push({
id: item.adapter.id,
gen: scanChainAdapterSource(item.adapter, item.source, item.keys),
});
}
}

Expand Down Expand Up @@ -334,12 +340,27 @@ export async function* scanAll(input: ScanAllInput): AsyncGenerator<MatchedAnnou
const entry = iterators.get(idx)!;
const seq = entry.seq++;
pending.set(idx, entry.iter.next());
yield {
chain: entry.chain,
timestamp: result.value.timestamp,
seq,
announcement: result.value.announcement,
} as MatchedAnnouncement;
const isBuiltIn =
entry.chain === 'evm' ||
entry.chain === 'stellar' ||
entry.chain === 'solana' ||
entry.chain === 'ckb';
yield (
isBuiltIn
? {
chain: entry.chain,
timestamp: result.value.timestamp,
seq,
announcement: result.value.announcement,
}
: {
chain: 'custom',
customChainId: entry.chain,
timestamp: result.value.timestamp,
seq,
announcement: result.value.announcement,
}
) as MatchedAnnouncement;
}
}
} finally {
Expand Down
10 changes: 8 additions & 2 deletions test/scanner/adapter-timestamps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ describe('custom adapter timestamps', () => {
adapters: [{ adapter, source: streamOf(items), keys: {} }],
});

expect(results.map((r) => r.chain)).toEqual(['fake', 'fake', 'fake']);
expect(results.map((r) => r.chain)).toEqual(['custom', 'custom', 'custom']);
expect(results.map((r) => (r.chain === 'custom' ? r.customChainId : undefined))).toEqual([
'fake',
'fake',
'fake',
]);
expect(results.map((r) => (r.announcement as FakeItem).id)).toEqual(['a', 'b', 'c']);
expect(results.map((r) => r.timestamp)).toEqual([1_700_000_100, 1_700_000_200, 1_700_000_300]);
// seq is the per-chain arrival counter and must stay monotonic alongside it.
Expand Down Expand Up @@ -153,7 +158,8 @@ describe('custom adapter timestamps', () => {
],
});

const byChain = (chain: string) => results.filter((r) => r.chain === chain);
const byChain = (customChainId: string) =>
results.filter((r) => r.chain === 'custom' && r.customChainId === customChainId);
expect(byChain('left').map((r) => r.timestamp)).toEqual([10, 20]);
expect(byChain('right').map((r) => r.timestamp)).toEqual([100, 200]);
expect(byChain('left').map((r) => r.seq)).toEqual([0, 1]);
Expand Down
138 changes: 138 additions & 0 deletions test/scanner/unified.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, test, expectTypeOf } from 'vitest';
import type {
ChainScannerAdapter,
CustomChainInput,
ScanAllInput,
MatchedAnnouncement,
} from '../../src/scanner/unified';
import type { MatchedAnnouncement as EvmMatchedAnnouncement } from '../../src/chains/evm/types';
import type { MatchedAnnouncement as StellarMatchedAnnouncement } from '../../src/chains/stellar/types';
import type { MatchedAnnouncement as SolanaMatchedAnnouncement } from '../../src/chains/solana/types';
import type { MatchedStealthCell as CkbMatchedCell } from '../../src/chains/ckb/types';

interface FooAnnouncement {
txId: string;
recipientKey: string;
}

interface FooKeys {
viewingKey: string;
}

interface FooMatched {
matchedTxId: string;
}

describe('ChainScannerAdapter / CustomChainInput type safety', () => {
test('a fully-typed adapter narrows scan(), decodeMetaAddress(), and timestampOf() correctly', () => {
const fooAdapter: ChainScannerAdapter<FooAnnouncement, FooKeys, FooMatched, string> = {
id: 'foo',
scan: async function* (source, keys) {
expectTypeOf(source).toEqualTypeOf<AsyncIterable<FooAnnouncement>>();
expectTypeOf(keys).toEqualTypeOf<FooKeys>();
yield { matchedTxId: 'abc' };
},
decodeMetaAddress: (metaAddress) => {
expectTypeOf(metaAddress).toBeString();
return 'decoded';
},
encodeMetaAddress: (spendingPubKey, viewingPubKey) => {
// The public API intentionally types these as `unknown` — every chain
// encodes different key material, so a real adapter must narrow itself.
expectTypeOf(spendingPubKey).toBeUnknown();
expectTypeOf(viewingPubKey).toBeUnknown();
return 'st:foo:...';
},
timestampOf: (matched) => {
expectTypeOf(matched).toEqualTypeOf<FooMatched>();
return 0;
},
};

expectTypeOf(fooAdapter.id).toBeString();
});

test('CustomChainInput requires adapter/source/keys to agree on generic parameters', () => {
const fooAdapter: ChainScannerAdapter<FooAnnouncement, FooKeys, FooMatched> = {
id: 'foo',
scan: async function* () {},
decodeMetaAddress: () => ({}),
encodeMetaAddress: () => '',
};

const validInput: CustomChainInput<FooAnnouncement, FooKeys, FooMatched> = {
adapter: fooAdapter,
source: (async function* () {})(),
keys: { viewingKey: 'vk' },
};
expectTypeOf(validInput).toMatchTypeOf<
CustomChainInput<FooAnnouncement, FooKeys, FooMatched>
>();

const mismatchedKeys: CustomChainInput<FooAnnouncement, FooKeys, FooMatched> = {
adapter: fooAdapter,
source: (async function* () {})(),
// @ts-expect-error keys must match the adapter's TKeys — a string is not FooKeys
keys: 'not-foo-keys',
};
});

test('ScanAllInput.adapters accepts a heterogeneous array of CustomChainInput without any', () => {
const fooAdapter: ChainScannerAdapter<FooAnnouncement, FooKeys, FooMatched> = {
id: 'foo',
scan: async function* () {},
decodeMetaAddress: () => ({}),
encodeMetaAddress: () => '',
};
const fooInput: CustomChainInput<FooAnnouncement, FooKeys, FooMatched> = {
adapter: fooAdapter,
source: (async function* () {})(),
keys: { viewingKey: 'vk' },
};

interface BarKeys {
spendKey: string;
}
const barAdapter: ChainScannerAdapter<string, BarKeys, number> = {
id: 'bar',
scan: async function* () {},
decodeMetaAddress: () => ({}),
encodeMetaAddress: () => '',
};
const barInput: CustomChainInput<string, BarKeys, number> = {
adapter: barAdapter,
source: (async function* () {})(),
keys: { spendKey: 'sk' },
};

// Two structurally different CustomChainInput instantiations coexist in one
// array — this is exactly what previously required `| any` to express.
const input: ScanAllInput = {
adapters: [fooInput, barInput],
};
expectTypeOf(input.adapters).not.toBeAny();
});

test('MatchedAnnouncement discriminates on chain, including the custom-adapter arm', () => {
let matched!: MatchedAnnouncement;

if (matched.chain === 'evm') {
const a: EvmMatchedAnnouncement = matched.announcement;
void a;
} else if (matched.chain === 'stellar') {
const a: StellarMatchedAnnouncement = matched.announcement;
void a;
} else if (matched.chain === 'solana') {
const a: SolanaMatchedAnnouncement = matched.announcement;
void a;
} else if (matched.chain === 'ckb') {
const a: CkbMatchedCell = matched.announcement;
void a;
} else {
const customChainId: string = matched.customChainId;
const announcement: unknown = matched.announcement;
void customChainId;
void announcement;
}
});
});
10 changes: 8 additions & 2 deletions test/scanner/unified.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,9 +492,15 @@ describe('ChainScannerAdapter conformance & custom adapters', () => {
const results = await collect(scanAll({ adapters: [customChain] }));

expect(results).toHaveLength(2);
expect(results[0].chain).toBe('custom-fixture-chain');
expect(results[0].chain).toBe('custom');
if (results[0].chain === 'custom') {
expect(results[0].customChainId).toBe('custom-fixture-chain');
}
expect(results[0].announcement).toEqual({ matchedId: '1', value: 100 });
expect(results[1].chain).toBe('custom-fixture-chain');
expect(results[1].chain).toBe('custom');
if (results[1].chain === 'custom') {
expect(results[1].customChainId).toBe('custom-fixture-chain');
}
expect(results[1].announcement).toEqual({ matchedId: '3', value: 300 });
});
});
Loading
Loading