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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions etc/sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ChainScannerAdapter<TItem = any, TKeys = any, TMatched = any, T
encodeMetaAddress(spendingPubKey: any, viewingPubKey: any): string;
id: string;
scan(source: AsyncIterable<TItem>, keys: TKeys): AsyncGenerator<TMatched>;
timestampOf?(matched: TMatched): number | undefined;
}

// @public (undocumented)
Expand Down Expand Up @@ -377,6 +378,9 @@ export interface Tracer {
startSpan(name: string, attributes?: Record<string, string | number | boolean>): Span;
}

// @public
export const UNKNOWN_TIMESTAMP = 0;

// @public (undocumented)
export class UnsupportedAssetError extends WraithBuilderError {
constructor(asset: string, chain?: string);
Expand Down Expand Up @@ -476,10 +480,10 @@ export abstract class WraithNetworkError extends WraithError {

// Warnings were encountered during analysis:
//
// dist/unified-DfldjjAV.d.ts:136:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts
// dist/unified-DfldjjAV.d.ts:141:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts
// dist/unified-DfldjjAV.d.ts:146:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts
// dist/unified-DfldjjAV.d.ts:151:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts
// 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

// (No @packageDocumentation comment for this package)

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @internal
*/
export { installReactNativePolyfills } from './compat';
export { scanAll } from './scanner/unified';
export { scanAll, UNKNOWN_TIMESTAMP } from './scanner/unified';
export {
deriveStealthKeysFromWallet,
FreighterWalletAdapter,
Expand Down
75 changes: 74 additions & 1 deletion src/scanner/unified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ import { adapter as ckbAdapter } from '../chains/ckb/scan';
*/
export type SupportedChain = 'evm' | 'stellar' | 'solana' | 'ckb';

/**
* Timestamp used when a scanner adapter cannot supply a real chain time.
*
* `scanAll` sorts nothing, it interleaves as results arrive, so this value is not
* an ordering key. It is the documented "unknown" marker on
* {@link MatchedAnnouncement.timestamp}: callers that sort or bucket by time
* should treat it as absent rather than as the Unix epoch.
*/
export const UNKNOWN_TIMESTAMP = 0;

/**
* Interface that all chain scanner adapters must implement for third-party chain extensibility.
*
Expand Down Expand Up @@ -60,6 +70,28 @@ export interface ChainScannerAdapter<TItem = any, TKeys = any, TMatched = any, T
* @param viewingPubKey - Public key used for viewing/ECDH derivation.
*/
encodeMetaAddress(spendingPubKey: any, viewingPubKey: any): string;

/**
* Returns the chain time of a matched result, in whole seconds since the Unix
* epoch.
*
* This is the timestamp contract for third-party adapters. Implement it when
* the chain exposes a block, ledger or slot time, and `scanAll` will carry the
* value through on {@link MatchedAnnouncement.timestamp}.
*
* Return `undefined` for a match whose time is genuinely unknown. Anything
* that is not a finite, non-negative number is treated the same way, so a
* partial implementation degrades instead of emitting `NaN` downstream.
*
* Adapters that already carry a numeric `timestamp` on the matched value do
* not need this method: `scanAll` reads that field as a fallback. When both
* are present, this method wins.
*
* Omitting it entirely is supported. Every match then reports
* {@link UNKNOWN_TIMESTAMP}, which is the behaviour every adapter had before
* this contract existed.
*/
timestampOf?(matched: TMatched): number | undefined;
}

/**
Expand Down Expand Up @@ -142,6 +174,47 @@ export type MatchedAnnouncement =
announcement: any;
};

/**
* Narrows an adapter-supplied timestamp to a usable chain time.
*
* Accepts only finite, non-negative numbers. `NaN`, `Infinity`, negative values
* and non-numbers all collapse to `undefined` so a malformed adapter cannot put
* a poisoned value on a matched announcement.
*/
function coerceTimestamp(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}

/**
* Resolves the timestamp for one matched result.
*
* Order is deliberate: an explicit `timestampOf` beats a field on the value,
* because the adapter author opted into the contract. A throwing `timestampOf`
* must not abort a scan that is otherwise fine, so it degrades to the fallback.
*/
function resolveTimestamp(
adapter: ChainScannerAdapter<any, any, any, any>,
matched: unknown,
): number {
if (typeof adapter.timestampOf === 'function') {
let reported: unknown;
try {
reported = adapter.timestampOf(matched);
} catch {
reported = undefined;
}
const fromMethod = coerceTimestamp(reported);
if (fromMethod !== undefined) return fromMethod;
}

if (matched !== null && typeof matched === 'object' && 'timestamp' in matched) {
const fromField = coerceTimestamp((matched as { timestamp?: unknown }).timestamp);
if (fromField !== undefined) return fromField;
}

return UNKNOWN_TIMESTAMP;
}

async function* scanChainAdapterSource(
adapter: ChainScannerAdapter<any, any, any, any>,
source: AsyncIterable<any>,
Expand All @@ -153,7 +226,7 @@ async function* scanChainAdapterSource(
while (true) {
const next = await it.next();
if (next.done) break;
yield { announcement: next.value, timestamp: 0 };
yield { announcement: next.value, timestamp: resolveTimestamp(adapter, next.value) };
}
} finally {
await it.return?.(undefined);
Expand Down
162 changes: 162 additions & 0 deletions test/scanner/adapter-timestamps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, test, expect } from 'vitest';
import { scanAll, UNKNOWN_TIMESTAMP } from '../../src/scanner/unified';
import type {
ChainScannerAdapter,
MatchedAnnouncement,
ScanAllInput,
} from '../../src/scanner/unified';

/** Announcement shape for the fake third-party chain used throughout this file. */
interface FakeItem {
id: string;
at?: unknown;
}

async function* streamOf(items: FakeItem[]): AsyncIterable<FakeItem> {
for (const item of items) yield item;
}

/**
* Builds a custom adapter. `timestampOf` is attached only when supplied so the
* omitted case exercises the genuine "adapter predates the contract" path
* rather than an adapter that returns undefined.
*/
function makeAdapter(
id: string,
timestampOf?: (matched: FakeItem) => number | undefined,
): ChainScannerAdapter<FakeItem, unknown, FakeItem, unknown> {
const adapter: ChainScannerAdapter<FakeItem, unknown, FakeItem, unknown> = {
id,
async *scan(source: AsyncIterable<FakeItem>) {
for await (const item of source) yield item;
},
decodeMetaAddress: () => ({}),
encodeMetaAddress: () => '',
};
if (timestampOf) adapter.timestampOf = timestampOf;
return adapter;
}

async function collect(input: ScanAllInput): Promise<MatchedAnnouncement[]> {
const out: MatchedAnnouncement[] = [];
for await (const match of scanAll(input)) out.push(match);
return out;
}

describe('custom adapter timestamps', () => {
test('timestampOf values reach the matched announcement in source order', async () => {
const items: FakeItem[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const times: Record<string, number> = { a: 1_700_000_100, b: 1_700_000_200, c: 1_700_000_300 };
const adapter = makeAdapter('fake', (m) => times[m.id]);

const results = await collect({
adapters: [{ adapter, source: streamOf(items), keys: {} }],
});

expect(results.map((r) => r.chain)).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.
expect(results.map((r) => r.seq)).toEqual([0, 1, 2]);
});

test('a timestamp field on the matched value is used when timestampOf is absent', async () => {
const adapter = makeAdapter('field-only');
const results = await collect({
adapters: [
{
adapter,
source: streamOf([
{ id: 'a', at: 0 },
{ id: 'b', at: 0 },
] as FakeItem[]),
keys: {},
},
],
});
// The fake items above carry no `timestamp`, so this is the fallback case.
expect(results.map((r) => r.timestamp)).toEqual([UNKNOWN_TIMESTAMP, UNKNOWN_TIMESTAMP]);

const withField = makeAdapter('field');
const fielded = await collect({
adapters: [
{
adapter: withField,
source: streamOf([
{ id: 'a', timestamp: 42 } as unknown as FakeItem,
{ id: 'b', timestamp: 43 } as unknown as FakeItem,
]),
keys: {},
},
],
});
expect(fielded.map((r) => r.timestamp)).toEqual([42, 43]);
});

test('timestampOf wins over a timestamp field on the value', async () => {
const adapter = makeAdapter('both', () => 999);
const results = await collect({
adapters: [
{
adapter,
source: streamOf([{ id: 'a', timestamp: 1 } as unknown as FakeItem]),
keys: {},
},
],
});
expect(results[0].timestamp).toBe(999);
});

test('an adapter with no timestamp support still scans, reporting UNKNOWN_TIMESTAMP', async () => {
const adapter = makeAdapter('legacy');
const results = await collect({
adapters: [{ adapter, source: streamOf([{ id: 'a' }, { id: 'b' }]), keys: {} }],
});
expect(results).toHaveLength(2);
expect(results.every((r) => r.timestamp === UNKNOWN_TIMESTAMP)).toBe(true);
});

test.each([
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['negative', -1],
['a string', '1700000000' as unknown as number],
['null', null as unknown as number],
])('a %s timestamp degrades to UNKNOWN_TIMESTAMP instead of propagating', async (_label, bad) => {
const adapter = makeAdapter('bad', () => bad);
const results = await collect({
adapters: [{ adapter, source: streamOf([{ id: 'a' }]), keys: {} }],
});
expect(results[0].timestamp).toBe(UNKNOWN_TIMESTAMP);
expect(Number.isFinite(results[0].timestamp)).toBe(true);
});

test('a throwing timestampOf does not abort the scan', async () => {
const adapter = makeAdapter('throws', () => {
throw new Error('adapter blew up');
});
const results = await collect({
adapters: [{ adapter, source: streamOf([{ id: 'a' }, { id: 'b' }]), keys: {} }],
});
expect(results.map((r) => (r.announcement as FakeItem).id)).toEqual(['a', 'b']);
expect(results.map((r) => r.timestamp)).toEqual([UNKNOWN_TIMESTAMP, UNKNOWN_TIMESTAMP]);
});

test('two custom chains keep their own timestamps and their own seq counters', async () => {
const left = makeAdapter('left', (m) => Number(m.id) * 10);
const right = makeAdapter('right', (m) => Number(m.id) * 100);

const results = await collect({
adapters: [
{ adapter: left, source: streamOf([{ id: '1' }, { id: '2' }]), keys: {} },
{ adapter: right, source: streamOf([{ id: '1' }, { id: '2' }]), keys: {} },
],
});

const byChain = (chain: string) => results.filter((r) => r.chain === chain);
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]);
expect(byChain('right').map((r) => r.seq)).toEqual([0, 1]);
});
});
Loading