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
3 changes: 3 additions & 0 deletions apps/cli/src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export function createDefaultConfig(): AntseedConfig {
payments: {
preferredMethod: 'crypto',
platformFeeRate: 0.05,
privy: {
appId: 'cmpmsn8sq00lp0cjvjqubzony',
},
crypto: {
chainId: 'base-mainnet',
},
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ export interface PaymentsCLIConfig {
maxPerRequestUsdc?: string;
/** Maximum total USDC the buyer will reserve in a single SpendingAuth in base units. Default: "1000000" ($1.00). */
maxReserveAmountUsdc?: string;
/** Privy portal onboarding settings */
privy?: {
/** Public Privy app ID used by the payments portal */
appId: string;
};
/**
* Optional on-chain seller contract (e.g. DiemStakingProxy). When set, the
* peer publishes it in metadata; buyers verify the binding via
Expand Down
20 changes: 20 additions & 0 deletions apps/cli/src/proxy/buyer-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,26 @@ test('parsePersistedPeers keeps peer with missing lastSeen but valid lastReached
assert.equal(result[0]?.lastReachedAt, NOW - 10_000)
})

test('parsePersistedPeers keeps peer with stale discovery but recent keepalive reachability', () => {
const result = parsePersistedPeers(
{
discoveredPeers: [
{
peerId: validPeerId,
providers: ['openai'],
lastSeen: NOW - MAX_AGE_MS - 60_000,
lastReachedAt: NOW - 15_000,
keepaliveLatencyMs: 42,
},
],
},
NOW,
)
assert.equal(result.length, 1)
assert.equal(result[0]?.lastReachedAt, NOW - 15_000)
assert.equal(result[0]?.keepaliveLatencyMs, 42)
})

test('parsePersistedPeers drops peer when both lastSeen and lastReachedAt are stale', () => {
const result = parsePersistedPeers(
{
Expand Down
57 changes: 45 additions & 12 deletions apps/cli/src/proxy/buyer-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from 'node:path'
import {
computeOnChainReputationScore,
type AntseedNode,
type PeerKeepaliveTelemetry,
type PeerInfo,
type PeerMetadata,
type RequestStreamResponseMetadata,
Expand Down Expand Up @@ -247,7 +248,10 @@ export function parsePersistedPeers(
const lastReachedAt = typeof entry.lastReachedAt === 'number' && Number.isFinite(entry.lastReachedAt)
? entry.lastReachedAt
: 0
// Keep if either DHT observation or successful transport contact is within window.
const keepaliveLatencyMs = typeof entry.keepaliveLatencyMs === 'number' && Number.isFinite(entry.keepaliveLatencyMs)
? entry.keepaliveLatencyMs
: 0
// Keep if DHT observation or successful transport contact is within window.
const freshnessAnchor = Math.max(lastSeen, lastReachedAt)
if (freshnessAnchor <= 0 || nowMs - freshnessAnchor >= maxAgeMs) continue

Expand All @@ -257,6 +261,7 @@ export function parsePersistedPeers(
providers,
}
if (lastReachedAt > 0) peer.lastReachedAt = lastReachedAt
if (keepaliveLatencyMs > 0) peer.keepaliveLatencyMs = keepaliveLatencyMs
if (typeof entry.displayName === 'string') peer.displayName = entry.displayName
if (typeof entry.publicAddress === 'string') peer.publicAddress = entry.publicAddress
if (entry.providerPricing && typeof entry.providerPricing === 'object') {
Expand Down Expand Up @@ -388,13 +393,18 @@ export class BuyerProxy {
})

const eventNode = this._node as AntseedNode & {
on?: (event: 'peers:discovered', listener: (peers: PeerInfo[]) => void) => unknown
on?: (event: string, listener: (...args: unknown[]) => void) => unknown
}
if (typeof eventNode.on === 'function') {
eventNode.on('peers:discovered', (peers: PeerInfo[]) => {
if (peers.length === 0) return
log(`Background discovery found ${peers.length} peer(s)`)
this._replacePeers(peers)
eventNode.on('peers:discovered', (peers: unknown) => {
if (!Array.isArray(peers)) return
const typedPeers = peers as PeerInfo[]
if (typedPeers.length === 0) return
log(`Background discovery found ${typedPeers.length} peer(s)`)
this._replacePeers(typedPeers)
})
eventNode.on('keepalive:pong', (telemetry: unknown) => {
this._rememberKeepaliveTelemetry(telemetry as PeerKeepaliveTelemetry)
})
}
}
Expand Down Expand Up @@ -565,15 +575,21 @@ export class BuyerProxy {
const prevById = new Map(this._cachedPeers.map((p) => [p.peerId, p]))
const now = Date.now()

// For peers re-observed in this scan, preserve `lastReachedAt` from the
// previous cache entry — the DHT announcement doesn't carry that field,
// and losing it on each refresh would defeat the carry-forward tracking.
// For peers re-observed in this scan, preserve local liveness telemetry from
// the previous cache entry — DHT announcements don't carry those fields.
const merged: PeerInfo[] = incoming.map((peer) => {
const prev = prevById.get(peer.peerId)
if (prev?.lastReachedAt && (!peer.lastReachedAt || prev.lastReachedAt > peer.lastReachedAt)) {
return { ...peer, lastReachedAt: prev.lastReachedAt }
if (!prev) {
return peer
}
const next: PeerInfo = { ...peer }
if (prev.lastReachedAt && (!next.lastReachedAt || prev.lastReachedAt > next.lastReachedAt)) {
next.lastReachedAt = prev.lastReachedAt
}
if (prev.keepaliveLatencyMs !== undefined) {
next.keepaliveLatencyMs = prev.keepaliveLatencyMs
}
return peer
return next
})

// Carry forward previously known peers that are missing from this scan.
Expand Down Expand Up @@ -643,6 +659,7 @@ export class BuyerProxy {
sellerContract: p.metadata?.sellerContract ?? null,
lastSeen: p.lastSeen,
lastReachedAt: p.lastReachedAt ?? null,
keepaliveLatencyMs: p.keepaliveLatencyMs ?? null,
}
})
const onChainRefreshedAt = this._cachedPeers
Expand Down Expand Up @@ -722,6 +739,22 @@ export class BuyerProxy {
}
}

private _rememberKeepaliveTelemetry(telemetry: PeerKeepaliveTelemetry): void {
if (!telemetry || typeof telemetry.peerId !== 'string') return
const cached = this._cachedPeers.find((p) => p.peerId === telemetry.peerId)
if (!cached) return

const latencyMs = typeof telemetry.latencyMs === 'number' && Number.isFinite(telemetry.latencyMs)
? Math.max(0, Math.round(telemetry.latencyMs))
: null

if (latencyMs === null) return
cached.keepaliveLatencyMs = latencyMs
cached.lastReachedAt = Date.now()
this._peerFailures.delete(telemetry.peerId)
this._persistPeersToState()
}

private async _discoverPeersFromNetwork(): Promise<PeerInfo[]> {
log('Discovering peers via DHT...')
const peers = await this._node.discoverPeers()
Expand Down
41 changes: 37 additions & 4 deletions apps/desktop/src/main/config-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const DESKTOP_DEFAULT_MAX_OUTPUT_USD_PER_MILLION = 30;
export const DESKTOP_DEFAULT_MIN_PEER_REPUTATION = 0;
export const DESKTOP_DEFAULT_PEER_REFRESH_INTERVAL_MS = 5 * 60_000;
export const DESKTOP_DEFAULT_METADATA_FETCH_TIMEOUT_MS = 1500;
export const DESKTOP_DEFAULT_PRIVY_APP_ID = 'cmpmsn8sq00lp0cjvjqubzony';

const DEFAULT_CONFIG: Record<string, unknown> = {
identity: { displayName: 'AntSeed Node' },
Expand All @@ -32,7 +33,11 @@ const DEFAULT_CONFIG: Record<string, unknown> = {
metadataFetchTimeoutMs: DESKTOP_DEFAULT_METADATA_FETCH_TIMEOUT_MS,
},
network: { bootstrapNodes: [] },
payments: { preferredMethod: 'crypto', platformFeeRate: 0.05 },
payments: {
preferredMethod: 'crypto',
platformFeeRate: 0.05,
privy: { appId: DESKTOP_DEFAULT_PRIVY_APP_ID },
},
providers: [],
plugins: [],
};
Expand Down Expand Up @@ -140,6 +145,33 @@ function migrateDesktopBuyerDefaults(config: Record<string, unknown>): {
};
}

function migrateDesktopPaymentDefaults(config: Record<string, unknown>): {
config: Record<string, unknown>;
migrated: boolean;
} {
const payments = asRecordValue(config.payments);
const privy = asRecordValue(payments.privy);
const existingAppId = asString(privy.appId, '');

if (existingAppId.trim().length > 0) {
return { config, migrated: false };
}

return {
config: {
...config,
payments: {
...payments,
privy: {
...privy,
appId: DESKTOP_DEFAULT_PRIVY_APP_ID,
},
},
},
migrated: true,
};
}

/**
* Ensure config.json exists and legacy desktop defaults are migrated.
*/
Expand All @@ -155,9 +187,10 @@ export async function ensureConfig(configPath = DEFAULT_CONFIG_PATH): Promise<vo
return;
}

const migration = migrateDesktopBuyerDefaults(existing);
if (migration.migrated) {
await writeConfigAtomic(migration.config, configPath);
const buyerMigration = migrateDesktopBuyerDefaults(existing);
const paymentMigration = migrateDesktopPaymentDefaults(buyerMigration.config);
if (buyerMigration.migrated || paymentMigration.migrated) {
await writeConfigAtomic(paymentMigration.config, configPath);
}
}

Expand Down
32 changes: 30 additions & 2 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,35 @@ async function stopPaymentsPortal(): Promise<void> {
paymentsServer = null;
}

async function isHttpReachable(url: string, timeoutMs = 750): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
});
return response.status < 500;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}

async function resolvePaymentsPortalBaseUrl(): Promise<string> {
const fastifyUrl = `${LOCALHOST_URL}:${PAYMENTS_PORT}`;
if (!isDev) return fastifyUrl;

const devUrl = process.env['ANTSEED_PAYMENTS_DEV_URL'] || 'http://localhost:5175';
if (await isHttpReachable(devUrl)) {
return devUrl;
}

console.warn(`[desktop] Payments dev server unavailable at ${devUrl}; opening ${fastifyUrl}`);
return fastifyUrl;
}

ipcMain.handle('payments:open-portal', async (_event, tab?: string) => {
try {
await startPaymentsPortal();
Expand All @@ -347,8 +376,7 @@ ipcMain.handle('payments:open-portal', async (_event, tab?: string) => {
// In dev mode, open the Vite HMR dev server (which proxies /api to the Fastify port).
// Set ANTSEED_PAYMENTS_DEV_URL to override (default: http://localhost:5175).
// When dev mode is detected but the dev server is not reachable, we still fall back to the Fastify URL.
const devUrl = isDev ? (process.env['ANTSEED_PAYMENTS_DEV_URL'] || 'http://localhost:5175') : null;
const base = devUrl ?? `${LOCALHOST_URL}:${PAYMENTS_PORT}`;
const base = await resolvePaymentsPortalBaseUrl();
const url = qs ? `${base}?${qs}` : base;
const { default: open } = await import('open');
await open(url);
Expand Down
Loading