diff --git a/AGENTS.md b/AGENTS.md index 885bf23..ac5713f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Prefer **clean code over backwards compatibility**. Do not add legacy redirects, | `/signer/` | Signing Layer (`@1shotapi/ows-signer`) | | `src/lib/types/primitives/` | Wallet-local branded types (one file each) | | `src/lib/types/enum/` | Domain enums (`EAssetType`, `EWalletEventKind`, …) | -| `src/lib/types/business/` | Domain DTOs (e.g. `KnownAsset`, `TrackedAsset`) | +| `src/lib/types/domain/` | Domain DTOs (e.g. `KnownAsset`, `TrackedAsset`, `WalletConfig`) | | `src/lib/types/events/` | Domain event classes (one file each) | | `src/lib/interfaces/{business,data,utils}/` | Layer interfaces | | `src/lib/implementations/{business,data,utils}/` | Layer implementations | diff --git a/src/components/AssetDetails.tsx b/src/components/AssetDetails.tsx index f6455b7..7409924 100644 --- a/src/components/AssetDetails.tsx +++ b/src/components/AssetDetails.tsx @@ -2,15 +2,12 @@ import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { useShallow } from "zustand/react/shallow"; import { - ArrowDownLeftIcon, - ArrowUpRightIcon, PlusIcon, QrCodeIcon, SendIcon, - ShoppingBagIcon, } from "lucide-react"; import { AddressUtils } from "@1shotapi/ows-wallet-utils"; -import type { TrackedAsset } from "../lib/types/business"; +import type { TrackedAsset } from "../lib/types/domain"; import { EAssetType } from "../lib/types/enum"; import type { EVMChainId } from "@1shotapi/ows-types"; import { DEMO_CHAINS } from "../ows/demoChains"; @@ -20,71 +17,26 @@ import { useWallet } from "../wallet/WalletProvider"; import { resolveActiveAddress } from "../wallet/activeAddress"; import { useWalletSessionStore } from "../wallet/sessionStore"; import { BalanceDisplay } from "./BalanceDisplay"; +import { TransactionHistory } from "./TransactionHistory"; import { ReceiveModal } from "./modals/ReceiveModal"; import { PurchaseComingSoonModal } from "./modals/PurchaseComingSoonModal"; import { TransferTokensModal } from "./modals/TransferTokensModal"; const addressUtils = new AddressUtils(new DemoChainsBlockchainProvider()); -type ActivityKind = "received" | "sent" | "purchase"; - -interface IMockActivity { - kind: ActivityKind; - title: string; - when: string; - amount: string; - positive: boolean; -} - -const MOCK_ACTIVITY: IMockActivity[] = [ - { - kind: "received", - title: "Received from 0x…4a2b", - when: "Today, 2:45 PM", - amount: "+10.00", - positive: true, - }, - { - kind: "sent", - title: "Sent to alex.eth", - when: "Yesterday, 11:12 AM", - amount: "-5.00", - positive: false, - }, - { - kind: "purchase", - title: "Purchase from Uniswap", - when: "Oct 24, 09:30 AM", - amount: "+50.00", - positive: true, - }, -]; - function chainLabel(chainId: EVMChainId): string { return ( DEMO_CHAINS.find((chain) => chain.chainId === chainId)?.label ?? chainId ); } -function ActivityIcon({ kind }: { kind: ActivityKind }) { - const className = "size-4 text-muted-foreground"; - switch (kind) { - case "received": - return ; - case "sent": - return ; - case "purchase": - return ; - } -} - export interface IAssetDetailsProps { asset: TrackedAsset; } /** * Shared focused-asset / asset-detail shell. - * Balance is live; recent activity remains mock for now. + * Balance and recent activity are live. */ export function AssetDetails({ asset }: IAssetDetailsProps) { const { style } = useStyle(); @@ -165,45 +117,7 @@ export function AssetDetails({ asset }: IAssetDetailsProps) { -
-
-

- Recent Activity -

- -
-
    - {MOCK_ACTIVITY.map((item) => ( -
  • -
    - -
    -
    -

    - {item.title} -

    -

    {item.when}

    -
    -

    - {item.amount} {asset.symbol} -

    -
  • - ))} -
-
+ {receiveOpen ? ( ; + } + return ; +} + +/** + * Recent ERC-20 transfers for a tracked asset (Alchemy + optimistic local sends). + */ +export function TransactionHistory({ + asset, + owner, +}: ITransactionHistoryProps) { + const { listAssetActivity } = useWallet(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!owner) { + setRows([]); + setError(null); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const next = await listAssetActivity(owner, asset, RECENT_LIMIT); + setRows(next); + } catch (err: unknown) { + console.error("[oneshot-wallet] activity load failed", err); + setError( + err instanceof Error ? err.message : "Failed to load activity", + ); + setRows([]); + } finally { + setLoading(false); + } + }, [asset, listAssetActivity, owner]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useTransactionHistoryUpdated( + useCallback( + (event) => { + if ( + event.trackedAssetId !== undefined && + event.trackedAssetId !== asset.id + ) { + return; + } + void refresh(); + }, + [asset.id, refresh], + ), + ); + + const viewAllUrl = + owner !== null ? demoAddressExplorerUrl(asset.chainId, String(owner)) : null; + + return ( +
+
+

+ Recent Activity +

+ {viewAllUrl ? ( + + View all + + ) : ( + + )} +
+ + {loading && rows.length === 0 ? ( +

Loading…

+ ) : null} + {error && rows.length === 0 ? ( +

+ {error} +

+ ) : null} + {!loading && !error && rows.length === 0 ? ( +

No recent activity.

+ ) : null} + + {rows.length > 0 ? ( +
    + {rows.map((item) => { + const explorerUrl = demoTxExplorerUrl( + item.chainId, + String(item.hash), + ); + const positive = item.kind === EAssetActivityKind.Received; + const content = ( + <> +
    + +
    +
    +

    + {activityTitle(item)} +

    +

    + {formatWhen(item.timestampMs)} +

    +
    +

    + {formatSignedAmount(item, asset.symbol)} +

    + + ); + + return ( +
  • + {explorerUrl ? ( + + {content} + + ) : ( +
    + {content} +
    + )} +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/components/balances/AssetList.tsx b/src/components/balances/AssetList.tsx index eb5354a..d048b57 100644 --- a/src/components/balances/AssetList.tsx +++ b/src/components/balances/AssetList.tsx @@ -23,7 +23,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { TrackedAsset } from "../../lib/types/business"; +import { TrackedAsset } from "../../lib/types/domain"; import { useStyle } from "../../style"; import { useWallet } from "../../wallet/WalletProvider"; import { useWalletSessionStore } from "../../wallet/sessionStore"; diff --git a/src/components/balances/BalancesTab.tsx b/src/components/balances/BalancesTab.tsx index 08c75d7..a773790 100644 --- a/src/components/balances/BalancesTab.tsx +++ b/src/components/balances/BalancesTab.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { TrackedAsset } from "../../lib/types/business"; +import type { TrackedAsset } from "../../lib/types/domain"; import { useStyle } from "../../style"; import { useWallet } from "../../wallet/WalletProvider"; import { useWalletSessionStore } from "../../wallet/sessionStore"; diff --git a/src/components/modals/TransferTokensModal.tsx b/src/components/modals/TransferTokensModal.tsx index 6b47c77..a8556dd 100644 --- a/src/components/modals/TransferTokensModal.tsx +++ b/src/components/modals/TransferTokensModal.tsx @@ -12,11 +12,12 @@ import { type EVMAccountAddress, type EVMTransactionHash, } from "@1shotapi/ows-types"; -import type { TrackedAsset } from "../../lib/types/business"; +import type { TrackedAsset } from "../../lib/types/domain"; import { EAssetType } from "../../lib/types/enum"; import { useStyle } from "../../style"; import { chainTechnologyFor } from "../../wallet/activeAddress"; import { useWallet } from "../../wallet/WalletProvider"; +import { useWalletSessionStore } from "../../wallet/sessionStore"; import { Modal } from "../Modal"; import { AddressInput, @@ -72,7 +73,7 @@ export function TransferTokensModal({ }: ITransferTokensModalProps) { const { style } = useStyle(); const copy = style.copy.transferTokens; - const { switchChain, sendTransaction } = useWallet(); + const { switchChain, sendTransaction, recordSentActivity } = useWallet(); const [amount, setAmount] = useState(""); const [recipientText, setRecipientText] = useState(""); const [recipient, setRecipient] = useState(null); @@ -80,6 +81,8 @@ export function TransferTokensModal({ const [busy, setBusy] = useState(false); const [sentHash, setSentHash] = useState(null); + const evmAddress = useWalletSessionStore((state) => state.evmAddress); + const technology = useMemo( () => chainTechnologyFor(asset.chainId), [asset.chainId], @@ -145,6 +148,17 @@ export function TransferTokensModal({ }) as Hex, ); const hash = await sendTransaction(asset.chainId, asset.address, data); + if (evmAddress) { + await recordSentActivity({ + chainId: asset.chainId, + tokenAddress: asset.address, + owner: evmAddress, + to: recipient as EVMAccountAddress, + amount: parsed, + decimals: asset.decimals, + hash, + }); + } onSuccess(hash); setSentHash(hash); } catch (error: unknown) { diff --git a/src/credentials/CachedRelayerCredentialRepository.ts b/src/credentials/CachedRelayerCredentialRepository.ts index f039f36..0304592 100644 --- a/src/credentials/CachedRelayerCredentialRepository.ts +++ b/src/credentials/CachedRelayerCredentialRepository.ts @@ -8,12 +8,11 @@ import { type ICredentialRepository, type StoredCredential, } from "@1shotapi/ows-types"; +import type { IConfigProvider } from "../lib/interfaces/utils/IConfigProvider"; import type { RelayerCredentialsClient } from "../relayer/RelayerCredentialsClient"; import { createRelayerAssertion } from "../relayer/webauthnAuth"; import { loadCredentialId } from "../storage"; -export const OWS_CREDENTIALS_STORAGE_KEY = "ows.credentials.v2"; - /** Minimal sync key/value API (localStorage or test double). */ export type CredentialStorageBackend = { getItem(key: string): string | null; @@ -30,7 +29,7 @@ type StoredBlob = { export interface ICachedRelayerCredentialRepositoryDeps { client: RelayerCredentialsClient; getSigner: () => OWSSigner; - storageKey?: string; + configProvider: IConfigProvider; storage?: CredentialStorageBackend; } @@ -77,13 +76,14 @@ function isStoredCredential(value: unknown): value is StoredCredential { export class CachedRelayerCredentialRepository implements ICredentialRepository { private readonly client: RelayerCredentialsClient; private readonly getSigner: () => OWSSigner; - private readonly storageKey: string; + private readonly configProvider: IConfigProvider; private readonly storage: CredentialStorageBackend; + private storageKey: string | null = null; constructor(deps: ICachedRelayerCredentialRepositoryDeps) { this.client = deps.client; this.getSigner = deps.getSigner; - this.storageKey = deps.storageKey ?? OWS_CREDENTIALS_STORAGE_KEY; + this.configProvider = deps.configProvider; this.storage = deps.storage ?? (typeof localStorage !== "undefined" @@ -91,7 +91,17 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository : createMemoryStorageBackend()); } + private async ensureStorageKey(): Promise { + if (this.storageKey) { + return this.storageKey; + } + const config = await this.configProvider.getConfig(); + this.storageKey = config.credentialsStorageKey; + return this.storageKey; + } + async store(credential: StoredCredential): Promise { + await this.ensureStorageKey(); const blob = this.readBlob(); const previousBlobId = blob.blobIds[credential.credentialId]; blob.credentials[credential.credentialId] = credential; @@ -130,6 +140,7 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository } async get(credentialId: CredentialId): Promise { + await this.ensureStorageKey(); const blob = this.readBlob(); if (blob.revoked.includes(credentialId)) { return undefined; @@ -138,6 +149,7 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository } async list(filter?: CredentialFilter): Promise { + await this.ensureStorageKey(); const blob = this.readBlob(); const active = Object.values(blob.credentials).filter( (c) => !blob.revoked.includes(c.credentialId), @@ -146,6 +158,7 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository } async delete(credentialId: CredentialId): Promise { + await this.ensureStorageKey(); const blob = this.readBlob(); const blobId = blob.blobIds[credentialId]; delete blob.credentials[credentialId]; @@ -166,6 +179,7 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository } async revoke(credentialId: CredentialId): Promise { + await this.ensureStorageKey(); const blob = this.readBlob(); if (blob.credentials[credentialId] && !blob.revoked.includes(credentialId)) { blob.revoked.push(credentialId); @@ -203,6 +217,7 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository * Requires the passkey to already be registered (done at wallet create). */ async refreshFromRelayer(): Promise { + await this.ensureStorageKey(); const assertion = await this.assert(); const { credentials: remote } = await this.client.recoverCredentials(assertion); @@ -265,7 +280,11 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository } private readBlob(): StoredBlob { - const raw = this.storage.getItem(this.storageKey); + const storageKey = this.storageKey; + if (!storageKey) { + return { credentials: {}, revoked: [], blobIds: {} }; + } + const raw = this.storage.getItem(storageKey); if (!raw) { return { credentials: {}, revoked: [], blobIds: {} }; } @@ -289,13 +308,17 @@ export class CachedRelayerCredentialRepository implements ICredentialRepository } private writeBlob(blob: StoredBlob): void { + const storageKey = this.storageKey; + if (!storageKey) { + return; + } const credCount = Object.keys(blob.credentials).length; const blobCount = Object.keys(blob.blobIds).length; if (credCount === 0 && blob.revoked.length === 0 && blobCount === 0) { - this.storage.removeItem(this.storageKey); + this.storage.removeItem(storageKey); return; } - this.storage.setItem(this.storageKey, JSON.stringify(blob)); + this.storage.setItem(storageKey, JSON.stringify(blob)); } } diff --git a/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts b/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts new file mode 100644 index 0000000..267c51a --- /dev/null +++ b/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts @@ -0,0 +1,341 @@ +import { + EVMAccountAddress, + EVMChainId, + EVMTransactionHash, + type EVMAccountAddress as EVMAccountAddressType, + type EVMChainId as EVMChainIdType, +} from "@1shotapi/ows-types"; +import { + createMemoryStorageBackend, + type CredentialStorageBackend, +} from "../../../demo/local-storage-store"; +import type { + IAssetActivityRepository, + IListAssetActivityParams, + IRecordSentActivityParams, +} from "../../interfaces/data/IAssetActivityRepository"; +import type { IConfigProvider } from "../../interfaces/utils/IConfigProvider"; +import type { IEventBus } from "../../interfaces/utils/IEventBus"; +import { AssetActivity } from "../../types/domain/AssetActivity"; +import { + EAssetActivityKind, + EAssetActivityStatus, +} from "../../types/enum"; +import { TransactionHistoryUpdatedEvent } from "../../types/events"; +import { + makeTrackedAssetId, + type TrackedAssetId, +} from "../../types/primitives"; + +type StoredOptimistic = { + hash: string; + chainId: string; + tokenAddress: string; + owner: string; + counterparty: string; + amount: string; + decimals: number; + timestampMs: number; +}; + +type StoredBlob = { + optimistic: StoredOptimistic[]; +}; + +/** Relayer `GET /wallet/activity` transfer row. */ +type RelayerActivityTransfer = { + hash?: string; + from?: string; + to?: string; + value?: string; + contractAddress?: string; + tokenDecimal?: number; + timestamp?: number; +}; + +type RelayerActivityPagedResponse = { + response?: RelayerActivityTransfer[]; + page?: number; + pageSize?: number; + totalResults?: number; +}; + +export type AssetActivityRepositoryOptions = { + storage?: CredentialStorageBackend; +}; + +/** + * ERC-20 activity via the 1Shot relayer (`GET /wallet/activity`, Blockscout-backed), + * merged with local optimistic sends. Falls back to optimistic rows when the + * indexer proxy fails. + */ +export class BlockscoutAssetActivityRepository + implements IAssetActivityRepository +{ + private readonly storage: CredentialStorageBackend; + + constructor( + private readonly eventBus: IEventBus, + private readonly configProvider: IConfigProvider, + options: AssetActivityRepositoryOptions = {}, + ) { + this.storage = + options.storage ?? + (typeof localStorage !== "undefined" + ? localStorage + : createMemoryStorageBackend()); + } + + async list(params: IListAssetActivityParams): Promise { + const config = await this.configProvider.getConfig(); + const limit = params.limit ?? config.assetActivityDefaultLimit; + const { owner, asset } = params; + const trackedAssetId = asset.id; + + const optimistic = this.readOptimistic(config.assetActivityStorageKey) + .filter( + (row) => + row.chainId === String(asset.chainId) && + row.tokenAddress.toLowerCase() === asset.address.toLowerCase() && + row.owner.toLowerCase() === owner.toLowerCase(), + ) + .map((row) => this.optimisticToActivity(row, trackedAssetId)); + + let indexed: AssetActivity[] = []; + try { + indexed = await this.fetchIndexed({ + owner, + chainId: asset.chainId, + tokenAddress: asset.address, + decimals: asset.decimals, + trackedAssetId, + limit, + relayerBaseUrl: config.relayerBaseUrl, + }); + } catch (error: unknown) { + console.warn( + "[asset-activity] Relayer activity unavailable; using local history", + error, + ); + } + + const indexedHashes = new Set( + indexed.map((row) => String(row.hash).toLowerCase()), + ); + const pendingOptimistic = optimistic.filter( + (row) => !indexedHashes.has(String(row.hash).toLowerCase()), + ); + + const merged = [...pendingOptimistic, ...indexed].sort( + (a, b) => b.timestampMs - a.timestampMs, + ); + + return merged.slice(0, limit); + } + + async recordSent( + params: IRecordSentActivityParams, + ): Promise { + const config = await this.configProvider.getConfig(); + const trackedAssetId = makeTrackedAssetId( + params.chainId, + params.tokenAddress, + ); + const stored: StoredOptimistic = { + hash: String(params.hash), + chainId: String(params.chainId), + tokenAddress: String(params.tokenAddress), + owner: String(params.owner), + counterparty: String(params.to), + amount: params.amount.toString(), + decimals: params.decimals, + timestampMs: Date.now(), + }; + + const next = [ + stored, + ...this.readOptimistic(config.assetActivityStorageKey).filter( + (row) => row.hash.toLowerCase() !== stored.hash.toLowerCase(), + ), + ].slice(0, config.assetActivityMaxOptimistic); + this.writeOptimistic(config.assetActivityStorageKey, next); + + const activity = this.optimisticToActivity(stored, trackedAssetId); + this.eventBus.emit(new TransactionHistoryUpdatedEvent(trackedAssetId)); + return activity; + } + + private async fetchIndexed(args: { + owner: EVMAccountAddressType; + chainId: EVMChainIdType; + tokenAddress: EVMAccountAddressType; + decimals: number; + trackedAssetId: TrackedAssetId; + limit: number; + relayerBaseUrl: string; + }): Promise { + // Over-fetch so client-side token filtering still yields `limit` rows. + const pageSize = Math.min(Math.max(args.limit * 5, args.limit), 100); + const url = new URL(`${args.relayerBaseUrl}/wallet/activity`); + url.searchParams.set("chainid", String(args.chainId)); + url.searchParams.set("accountAddress", String(args.owner)); + url.searchParams.set("page", "1"); + url.searchParams.set("pageSize", String(pageSize)); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { accept: "application/json" }, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Relayer activity HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + + const payload = (await response.json()) as RelayerActivityPagedResponse; + const rows = Array.isArray(payload.response) ? payload.response : []; + const byHash = new Map(); + + for (const row of rows) { + const activity = this.transferToActivity(row, args); + if (!activity) { + continue; + } + const key = String(activity.hash).toLowerCase(); + if (!byHash.has(key)) { + byHash.set(key, activity); + } + } + + return [...byHash.values()] + .sort((a, b) => b.timestampMs - a.timestampMs) + .slice(0, args.limit); + } + + private transferToActivity( + transfer: RelayerActivityTransfer, + args: { + owner: EVMAccountAddressType; + chainId: EVMChainIdType; + tokenAddress: EVMAccountAddressType; + decimals: number; + trackedAssetId: TrackedAssetId; + }, + ): AssetActivity | null { + const hash = transfer.hash; + const from = transfer.from; + const to = transfer.to; + if (!hash || !from || !to) { + return null; + } + + if (transfer.contractAddress) { + if ( + transfer.contractAddress.toLowerCase() !== + String(args.tokenAddress).toLowerCase() + ) { + return null; + } + } + + if (!transfer.value) { + return null; + } + let amount: bigint; + try { + amount = BigInt(transfer.value); + } catch { + return null; + } + if (amount <= 0n) { + return null; + } + + let decimals = args.decimals; + if (transfer.tokenDecimal != null) { + const parsed = Number(transfer.tokenDecimal); + if (Number.isFinite(parsed)) { + decimals = parsed; + } + } + + const ownerLower = String(args.owner).toLowerCase(); + const fromLower = from.toLowerCase(); + const toLower = to.toLowerCase(); + let kind: EAssetActivityKind; + let counterparty: EVMAccountAddressType; + if (fromLower === ownerLower) { + kind = EAssetActivityKind.Sent; + counterparty = EVMAccountAddress(to as `0x${string}`); + } else if (toLower === ownerLower) { + kind = EAssetActivityKind.Received; + counterparty = EVMAccountAddress(from as `0x${string}`); + } else { + return null; + } + + return new AssetActivity( + EVMTransactionHash(hash as `0x${string}`), + args.chainId, + args.tokenAddress, + args.trackedAssetId, + args.owner, + counterparty, + amount, + decimals, + kind, + EAssetActivityStatus.Confirmed, + this.resolveTimestampMs(transfer.timestamp), + ); + } + + /** Relayer timestamps are unix seconds. */ + private resolveTimestampMs(unixSeconds: number | undefined): number { + if (unixSeconds == null) { + return 0; + } + const seconds = Number(unixSeconds); + if (!Number.isFinite(seconds) || seconds <= 0) { + return 0; + } + return Math.trunc(seconds * 1000); + } + + private optimisticToActivity( + row: StoredOptimistic, + trackedAssetId: TrackedAssetId, + ): AssetActivity { + return new AssetActivity( + EVMTransactionHash(row.hash as `0x${string}`), + EVMChainId(row.chainId as `0x${string}`), + EVMAccountAddress(row.tokenAddress as `0x${string}`), + trackedAssetId, + EVMAccountAddress(row.owner as `0x${string}`), + EVMAccountAddress(row.counterparty as `0x${string}`), + BigInt(row.amount), + row.decimals, + EAssetActivityKind.Sent, + EAssetActivityStatus.Pending, + row.timestampMs, + ); + } + + private readOptimistic(storageKey: string): StoredOptimistic[] { + try { + const raw = this.storage.getItem(storageKey); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as StoredBlob; + return Array.isArray(parsed.optimistic) ? parsed.optimistic : []; + } catch { + return []; + } + } + + private writeOptimistic(storageKey: string, rows: StoredOptimistic[]): void { + const blob: StoredBlob = { optimistic: rows }; + this.storage.setItem(storageKey, JSON.stringify(blob)); + } +} diff --git a/src/lib/implementations/data/HardcodedKnownAssetRepository.ts b/src/lib/implementations/data/HardcodedKnownAssetRepository.ts index 5e036d2..0846721 100644 --- a/src/lib/implementations/data/HardcodedKnownAssetRepository.ts +++ b/src/lib/implementations/data/HardcodedKnownAssetRepository.ts @@ -10,7 +10,7 @@ import type { IKnownAssetRepository } from "../../interfaces/data/IKnownAssetRep import { KnownAsset, NewTrackedAsset, -} from "../../types/business"; +} from "../../types/domain"; import { EAssetType } from "../../types/enum"; import { makeTrackedAssetId } from "@/lib/types/primitives"; diff --git a/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts b/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts index 4da892e..9090c21 100644 --- a/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts +++ b/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts @@ -10,13 +10,14 @@ import { createMemoryStorageBackend, type CredentialStorageBackend, } from "../../../demo/local-storage-store"; +import type { IConfigProvider } from "../../interfaces/utils/IConfigProvider"; import type { IEventBus } from "../../interfaces/utils/IEventBus"; import type { ITrackedAssetRepository } from "../../interfaces/data/ITrackedAssetRepository"; import { DEFAULT_TRACKED_USDC, isDefaultTrackedUsdc, } from "./HardcodedKnownAssetRepository"; -import { NewTrackedAsset, TrackedAsset } from "../../types/business"; +import { NewTrackedAsset, TrackedAsset } from "../../types/domain"; import { EAssetType } from "../../types/enum"; import { BalanceUpdatedEvent } from "../../types/events"; import { @@ -24,9 +25,6 @@ import { type TrackedAssetId, } from "../../types/primitives"; -/** localStorage key for user-tracked assets (defaults like USDC are merged in). */ -export const OWS_TRACKED_ASSETS_STORAGE_KEY = "ows.tracked-assets.v2"; - type StoredBlob = { assets: Array<{ chainId: string; @@ -40,7 +38,6 @@ type StoredBlob = { }; export type TrackedAssetRepositoryOptions = { - storageKey?: string; storage?: CredentialStorageBackend; }; @@ -49,16 +46,16 @@ const EMPTY_OWNER = EVMAccountAddress("0x0"); export class LocalStorageTrackedAssetRepository implements ITrackedAssetRepository { - private readonly storageKey: string; private readonly storage: CredentialStorageBackend; private readonly balanceCache = new Map(); + private storageKey: string | null = null; constructor( private readonly blockchain: IBlockchainProvider, private readonly eventBus: IEventBus, + private readonly configProvider: IConfigProvider, options: TrackedAssetRepositoryOptions = {}, ) { - this.storageKey = options.storageKey ?? OWS_TRACKED_ASSETS_STORAGE_KEY; this.storage = options.storage ?? (typeof localStorage !== "undefined" @@ -66,8 +63,18 @@ export class LocalStorageTrackedAssetRepository : createMemoryStorageBackend()); } + private async resolveStorageKey(): Promise { + if (this.storageKey) { + return this.storageKey; + } + const config = await this.configProvider.getConfig(); + this.storageKey = config.trackedAssetsStorageKey; + return this.storageKey; + } + async list(owner: EVMAccountAddressType): Promise { - const assets = this.mergeWithDefaults(this.readStoredAssets()); + const storageKey = await this.resolveStorageKey(); + const assets = this.mergeWithDefaults(this.readStoredAssets(storageKey)); return this.ensureBalances(assets, owner, false); } @@ -78,8 +85,9 @@ export class LocalStorageTrackedAssetRepository if (isDefaultTrackedUsdc(chainId, address)) { return true; } + const storageKey = await this.resolveStorageKey(); const key = makeTrackedAssetId(chainId, address); - return this.readStoredAssets().some((asset) => asset.id === key); + return this.readStoredAssets(storageKey).some((asset) => asset.id === key); } async add( @@ -92,7 +100,8 @@ export class LocalStorageTrackedAssetRepository return withBalance!; } - const assets = this.readStoredAssets(); + const storageKey = await this.resolveStorageKey(); + const assets = this.readStoredAssets(storageKey); const key = makeTrackedAssetId(asset.chainId, asset.address); const found = assets.find((a) => a.id === key); if (found) { @@ -102,7 +111,7 @@ export class LocalStorageTrackedAssetRepository const tracked = TrackedAsset.fromNew(asset); assets.push(tracked); - this.writeAssets(assets); + this.writeAssets(storageKey, assets); const [withBalance] = await this.ensureBalances([tracked], owner, false); return withBalance!; } @@ -114,9 +123,12 @@ export class LocalStorageTrackedAssetRepository if (isDefaultTrackedUsdc(chainId, address)) { return; } + const storageKey = await this.resolveStorageKey(); const key = makeTrackedAssetId(chainId, address); - const next = this.readStoredAssets().filter((asset) => asset.id !== key); - this.writeAssets(next); + const next = this.readStoredAssets(storageKey).filter( + (asset) => asset.id !== key, + ); + this.writeAssets(storageKey, next); this.balanceCache.delete(key); } @@ -129,7 +141,8 @@ export class LocalStorageTrackedAssetRepository } else { this.balanceCache.clear(); } - const assets = this.mergeWithDefaults(this.readStoredAssets()); + const storageKey = await this.resolveStorageKey(); + const assets = this.mergeWithDefaults(this.readStoredAssets(storageKey)); const targets = id ? assets.filter((asset) => asset.id === id) : assets; return this.ensureBalances(targets, owner, true); } @@ -202,8 +215,8 @@ export class LocalStorageTrackedAssetRepository } } - private readStoredAssets(): TrackedAsset[] { - const raw = this.storage.getItem(this.storageKey); + private readStoredAssets(storageKey: string): TrackedAsset[] { + const raw = this.storage.getItem(storageKey); if (!raw) return []; try { const parsed = JSON.parse(raw) as StoredBlob; @@ -244,7 +257,7 @@ export class LocalStorageTrackedAssetRepository } } - private writeAssets(assets: TrackedAsset[]): void { + private writeAssets(storageKey: string, assets: TrackedAsset[]): void { // Persist only user-added (non-default) rows as NewTrackedAsset fields + id. const userAssets = assets.filter( (asset) => !isDefaultTrackedUsdc(asset.chainId, asset.address), @@ -260,6 +273,6 @@ export class LocalStorageTrackedAssetRepository id: asset.id, })), }; - this.storage.setItem(this.storageKey, JSON.stringify(blob)); + this.storage.setItem(storageKey, JSON.stringify(blob)); } } diff --git a/src/lib/implementations/data/OneshotRelayerRepository.ts b/src/lib/implementations/data/OneshotRelayerRepository.ts index 0278ed8..34f0d66 100644 --- a/src/lib/implementations/data/OneshotRelayerRepository.ts +++ b/src/lib/implementations/data/OneshotRelayerRepository.ts @@ -17,6 +17,7 @@ import type { IOneshotRelayerRepository, ISendTransactionResult, } from "../../interfaces/data/IOneshotRelayerRepository"; +import type { IConfigProvider } from "../../interfaces/utils/IConfigProvider"; const ZERO_VALUE = HexString("0x0"); const EMPTY_DATA = HexString("0x"); @@ -30,9 +31,13 @@ export type OneshotRelayerRepositoryOptions = { /** * Interim submit path: prepare → passkey sign → eth_sendRawTransaction. * Returns a placeholder relayer id until the public relayer is wired. + * {@link IConfigProvider} supplies the relayer base URL for that future path. */ export class OneshotRelayerRepository implements IOneshotRelayerRepository { - constructor(private readonly options: OneshotRelayerRepositoryOptions) {} + constructor( + private readonly options: OneshotRelayerRepositoryOptions, + private readonly configProvider: IConfigProvider, + ) {} async sendTransaction( chainId: EVMChainId, @@ -42,6 +47,10 @@ export class OneshotRelayerRepository implements IOneshotRelayerRepository { _options?: { webhookDestination?: UriString }, ): Promise { void _options; + // Resolve env now so host→relayer mapping is exercised on every send; + // used when this path switches to the public relayer API. + await this.configProvider.getConfig(); + const signer = this.options.getSigner(); const chainRpc = this.options.getChainRpc(); const active = chainRpc.getChainId(); diff --git a/src/lib/implementations/data/index.ts b/src/lib/implementations/data/index.ts index fd4cd11..4221020 100644 --- a/src/lib/implementations/data/index.ts +++ b/src/lib/implementations/data/index.ts @@ -3,10 +3,9 @@ export { HardcodedKnownAssetRepository, isDefaultTrackedUsdc, } from "./HardcodedKnownAssetRepository"; -export { - LocalStorageTrackedAssetRepository, - OWS_TRACKED_ASSETS_STORAGE_KEY, -} from "./LocalStorageTrackedAssetRepository"; +export { LocalStorageTrackedAssetRepository } from "./LocalStorageTrackedAssetRepository"; export type { TrackedAssetRepositoryOptions } from "./LocalStorageTrackedAssetRepository"; +export { BlockscoutAssetActivityRepository } from "./BlockscoutAssetActivityRepository"; +export type { AssetActivityRepositoryOptions } from "./BlockscoutAssetActivityRepository"; export { OneshotRelayerRepository } from "./OneshotRelayerRepository"; export type { OneshotRelayerRepositoryOptions } from "./OneshotRelayerRepository"; diff --git a/src/lib/implementations/utils/ConfigProvider.ts b/src/lib/implementations/utils/ConfigProvider.ts new file mode 100644 index 0000000..fbb522c --- /dev/null +++ b/src/lib/implementations/utils/ConfigProvider.ts @@ -0,0 +1,45 @@ +import type { IConfigProvider } from "../../interfaces/utils/IConfigProvider"; +import { WalletConfig } from "../../types/domain/WalletConfig"; + +const PRODUCTION_WALLET_HOSTNAME = "wallet.1shotapi.com"; +const PRODUCTION_RELAYER_BASE_URL = "https://relayer.1shotapi.com"; +const DEVELOPMENT_RELAYER_BASE_URL = "https://relayer.1shotapi.dev"; + +const DEFAULT_ASSET_ACTIVITY_STORAGE_KEY = "ows.asset-activity.v1"; +const DEFAULT_TRACKED_ASSETS_STORAGE_KEY = "ows.tracked-assets.v2"; +const DEFAULT_CREDENTIALS_STORAGE_KEY = "ows.credentials.v2"; +const DEFAULT_ASSET_ACTIVITY_LIMIT = 10; +const DEFAULT_ASSET_ACTIVITY_MAX_OPTIMISTIC = 100; + +/** + * Resolves {@link WalletConfig} from the Branding Layer iframe host. + * Production wallet host → production relayer; all other hosts → dev relayer. + */ +export class ConfigProvider implements IConfigProvider { + private cached: WalletConfig | null = null; + + async getConfig(): Promise { + if (this.cached) { + return this.cached; + } + + this.cached = new WalletConfig( + this.resolveRelayerBaseUrl(), + DEFAULT_ASSET_ACTIVITY_STORAGE_KEY, + DEFAULT_TRACKED_ASSETS_STORAGE_KEY, + DEFAULT_CREDENTIALS_STORAGE_KEY, + DEFAULT_ASSET_ACTIVITY_LIMIT, + DEFAULT_ASSET_ACTIVITY_MAX_OPTIMISTIC, + ); + return this.cached; + } + + private resolveRelayerBaseUrl(): string { + const hostname = + typeof window !== "undefined" ? window.location.hostname : ""; + if (hostname === PRODUCTION_WALLET_HOSTNAME) { + return PRODUCTION_RELAYER_BASE_URL; + } + return DEVELOPMENT_RELAYER_BASE_URL; + } +} diff --git a/src/lib/implementations/utils/EventBus.ts b/src/lib/implementations/utils/EventBus.ts index 8529999..a777b61 100644 --- a/src/lib/implementations/utils/EventBus.ts +++ b/src/lib/implementations/utils/EventBus.ts @@ -3,6 +3,7 @@ import { EWalletEventKind } from "../../types/enum"; import type { BalanceUpdatedEvent, RefreshBalanceRequestedEvent, + TransactionHistoryUpdatedEvent, WalletDomainEvent, } from "../../types/events"; @@ -43,6 +44,15 @@ export class EventBus implements IEventBus { ); } + onTransactionHistoryUpdated( + handler: (event: TransactionHistoryUpdatedEvent) => void, + ): () => void { + return this.addListener( + EWalletEventKind.TransactionHistoryUpdated, + handler as Listener, + ); + } + private addListener( kind: EWalletEventKind, handler: Listener, diff --git a/src/lib/implementations/utils/index.ts b/src/lib/implementations/utils/index.ts index 26f8c85..1d29140 100644 --- a/src/lib/implementations/utils/index.ts +++ b/src/lib/implementations/utils/index.ts @@ -1,3 +1,4 @@ +export { ConfigProvider } from "./ConfigProvider"; export { DemoChainsBlockchainProvider } from "./DemoChainsBlockchainProvider"; export { EventBus } from "./EventBus"; export { TransactionUtils } from "./TransactionUtils"; diff --git a/src/lib/interfaces/data/IAssetActivityRepository.ts b/src/lib/interfaces/data/IAssetActivityRepository.ts new file mode 100644 index 0000000..ffe6fc1 --- /dev/null +++ b/src/lib/interfaces/data/IAssetActivityRepository.ts @@ -0,0 +1,35 @@ +import type { + EVMAccountAddress, + EVMChainId, + EVMTransactionHash, +} from "@1shotapi/ows-types"; +import type { AssetActivity } from "../../types/domain/AssetActivity"; +import type { TrackedAsset } from "../../types/domain/TrackedAsset"; + +export interface IRecordSentActivityParams { + chainId: EVMChainId; + tokenAddress: EVMAccountAddress; + owner: EVMAccountAddress; + to: EVMAccountAddress; + amount: bigint; + decimals: number; + hash: EVMTransactionHash; +} + +export interface IListAssetActivityParams { + owner: EVMAccountAddress; + asset: TrackedAsset; + /** Max rows to return (default 10). */ + limit?: number; +} + +export interface IAssetActivityRepository { + /** Indexed history merged with local optimistic sends. */ + list(params: IListAssetActivityParams): Promise; + /** Persist an in-wallet send until the indexer catches up. */ + recordSent(params: IRecordSentActivityParams): Promise; +} + +export const IAssetActivityRepositoryType = Symbol.for( + "IAssetActivityRepository", +); diff --git a/src/lib/interfaces/data/IKnownAssetRepository.ts b/src/lib/interfaces/data/IKnownAssetRepository.ts index fe71dde..80ea258 100644 --- a/src/lib/interfaces/data/IKnownAssetRepository.ts +++ b/src/lib/interfaces/data/IKnownAssetRepository.ts @@ -1,5 +1,5 @@ import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; -import type { KnownAsset, NewTrackedAsset } from "../../types/business"; +import type { KnownAsset, NewTrackedAsset } from "../../types/domain"; export interface IKnownAssetRepository { getKnownAsset( diff --git a/src/lib/interfaces/data/ITrackedAssetRepository.ts b/src/lib/interfaces/data/ITrackedAssetRepository.ts index 534bb85..d195e56 100644 --- a/src/lib/interfaces/data/ITrackedAssetRepository.ts +++ b/src/lib/interfaces/data/ITrackedAssetRepository.ts @@ -1,6 +1,6 @@ import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; import type { TrackedAssetId } from "../../types/primitives"; -import type { NewTrackedAsset, TrackedAsset } from "../../types/business"; +import type { NewTrackedAsset, TrackedAsset } from "../../types/domain"; export interface ITrackedAssetRepository { list(owner: EVMAccountAddress): Promise; diff --git a/src/lib/interfaces/data/index.ts b/src/lib/interfaces/data/index.ts index 1a99629..dce0f8e 100644 --- a/src/lib/interfaces/data/index.ts +++ b/src/lib/interfaces/data/index.ts @@ -2,6 +2,12 @@ export type { IKnownAssetRepository } from "./IKnownAssetRepository"; export { IKnownAssetRepositoryType } from "./IKnownAssetRepository"; export type { ITrackedAssetRepository } from "./ITrackedAssetRepository"; export { ITrackedAssetRepositoryType } from "./ITrackedAssetRepository"; +export type { + IAssetActivityRepository, + IListAssetActivityParams, + IRecordSentActivityParams, +} from "./IAssetActivityRepository"; +export { IAssetActivityRepositoryType } from "./IAssetActivityRepository"; export type { IOneshotRelayerRepository, ISendTransactionResult, diff --git a/src/lib/interfaces/utils/IConfigProvider.ts b/src/lib/interfaces/utils/IConfigProvider.ts new file mode 100644 index 0000000..7add66c --- /dev/null +++ b/src/lib/interfaces/utils/IConfigProvider.ts @@ -0,0 +1,7 @@ +import type { WalletConfig } from "../../types/domain/WalletConfig"; + +export interface IConfigProvider { + getConfig(): Promise; +} + +export const IConfigProviderType = Symbol.for("IConfigProvider"); diff --git a/src/lib/interfaces/utils/IEventBus.ts b/src/lib/interfaces/utils/IEventBus.ts index ba6573f..483f13a 100644 --- a/src/lib/interfaces/utils/IEventBus.ts +++ b/src/lib/interfaces/utils/IEventBus.ts @@ -1,6 +1,7 @@ import type { BalanceUpdatedEvent, RefreshBalanceRequestedEvent, + TransactionHistoryUpdatedEvent, WalletDomainEvent, } from "../../types/events"; @@ -14,6 +15,10 @@ export interface IEventBus { onRefreshBalanceRequested( handler: (event: RefreshBalanceRequestedEvent) => void, ): () => void; + + onTransactionHistoryUpdated( + handler: (event: TransactionHistoryUpdatedEvent) => void, + ): () => void; } export const IEventBusType = Symbol.for("IEventBus"); diff --git a/src/lib/interfaces/utils/index.ts b/src/lib/interfaces/utils/index.ts index a30c9f1..3a4e53e 100644 --- a/src/lib/interfaces/utils/index.ts +++ b/src/lib/interfaces/utils/index.ts @@ -1,3 +1,5 @@ +export type { IConfigProvider } from "./IConfigProvider"; +export { IConfigProviderType } from "./IConfigProvider"; export type { IEventBus } from "./IEventBus"; export { IEventBusType } from "./IEventBus"; export type { diff --git a/src/lib/types/domain/AssetActivity.ts b/src/lib/types/domain/AssetActivity.ts new file mode 100644 index 0000000..8d60862 --- /dev/null +++ b/src/lib/types/domain/AssetActivity.ts @@ -0,0 +1,26 @@ +import type { + EVMAccountAddress, + EVMChainId, + EVMTransactionHash, +} from "@1shotapi/ows-types"; +import type { EAssetActivityKind } from "../enum/EAssetActivityKind"; +import type { EAssetActivityStatus } from "../enum/EAssetActivityStatus"; +import type { TrackedAssetId } from "../primitives/TrackedAssetId"; + +/** One ERC-20 transfer involving the wallet owner for a tracked asset. */ +export class AssetActivity { + constructor( + public readonly hash: EVMTransactionHash, + public readonly chainId: EVMChainId, + public readonly tokenAddress: EVMAccountAddress, + public readonly trackedAssetId: TrackedAssetId, + public readonly owner: EVMAccountAddress, + public readonly counterparty: EVMAccountAddress, + public readonly amount: bigint, + public readonly decimals: number, + public readonly kind: EAssetActivityKind, + public readonly status: EAssetActivityStatus, + /** Unix timestamp in milliseconds. */ + public readonly timestampMs: number, + ) {} +} diff --git a/src/lib/types/business/KnownAsset.ts b/src/lib/types/domain/KnownAsset.ts similarity index 100% rename from src/lib/types/business/KnownAsset.ts rename to src/lib/types/domain/KnownAsset.ts diff --git a/src/lib/types/business/TrackedAsset.ts b/src/lib/types/domain/TrackedAsset.ts similarity index 100% rename from src/lib/types/business/TrackedAsset.ts rename to src/lib/types/domain/TrackedAsset.ts diff --git a/src/lib/types/domain/WalletConfig.ts b/src/lib/types/domain/WalletConfig.ts new file mode 100644 index 0000000..3d3c379 --- /dev/null +++ b/src/lib/types/domain/WalletConfig.ts @@ -0,0 +1,20 @@ +/** + * Runtime configuration for the 1Shot Branding Layer wallet. + * Built by {@link ConfigProvider} (relayer URL from the iframe host, etc.). + */ +export class WalletConfig { + public constructor( + /** Origin for credential + activity REST (no trailing slash). */ + public readonly relayerBaseUrl: string, + /** localStorage key for optimistic send history. */ + public readonly assetActivityStorageKey: string, + /** localStorage key for user-tracked assets. */ + public readonly trackedAssetsStorageKey: string, + /** localStorage key for the plaintext credentials cache. */ + public readonly credentialsStorageKey: string, + /** Default page size when listing asset activity. */ + public readonly assetActivityDefaultLimit: number, + /** Max optimistic send rows retained in localStorage. */ + public readonly assetActivityMaxOptimistic: number, + ) {} +} diff --git a/src/lib/types/business/index.ts b/src/lib/types/domain/index.ts similarity index 52% rename from src/lib/types/business/index.ts rename to src/lib/types/domain/index.ts index b3dafa5..2b7b609 100644 --- a/src/lib/types/business/index.ts +++ b/src/lib/types/domain/index.ts @@ -1,2 +1,4 @@ +export { AssetActivity } from "./AssetActivity"; export { KnownAsset } from "./KnownAsset"; export { NewTrackedAsset, TrackedAsset } from "./TrackedAsset"; +export { WalletConfig } from "./WalletConfig"; diff --git a/src/lib/types/enum/EAssetActivityKind.ts b/src/lib/types/enum/EAssetActivityKind.ts new file mode 100644 index 0000000..ffde548 --- /dev/null +++ b/src/lib/types/enum/EAssetActivityKind.ts @@ -0,0 +1,5 @@ +/** Direction of an ERC-20 transfer relative to the wallet owner. */ +export enum EAssetActivityKind { + Sent = "sent", + Received = "received", +} diff --git a/src/lib/types/enum/EAssetActivityStatus.ts b/src/lib/types/enum/EAssetActivityStatus.ts new file mode 100644 index 0000000..a5d0bf0 --- /dev/null +++ b/src/lib/types/enum/EAssetActivityStatus.ts @@ -0,0 +1,5 @@ +/** Confirmation state for an activity row (optimistic vs indexer). */ +export enum EAssetActivityStatus { + Pending = "pending", + Confirmed = "confirmed", +} diff --git a/src/lib/types/enum/EWalletEventKind.ts b/src/lib/types/enum/EWalletEventKind.ts index 169ce2f..4b33301 100644 --- a/src/lib/types/enum/EWalletEventKind.ts +++ b/src/lib/types/enum/EWalletEventKind.ts @@ -2,4 +2,5 @@ export enum EWalletEventKind { RefreshBalanceRequested = "refreshBalanceRequested", BalanceUpdated = "balanceUpdated", + TransactionHistoryUpdated = "transactionHistoryUpdated", } diff --git a/src/lib/types/enum/index.ts b/src/lib/types/enum/index.ts index 918128e..11bb42c 100644 --- a/src/lib/types/enum/index.ts +++ b/src/lib/types/enum/index.ts @@ -1,3 +1,5 @@ +export { EAssetActivityKind } from "./EAssetActivityKind"; +export { EAssetActivityStatus } from "./EAssetActivityStatus"; export { EAssetType } from "./EAssetType"; export { EPasskeyPromptReason } from "./EPasskeyPromptReason"; export { EWalletEventKind } from "./EWalletEventKind"; diff --git a/src/lib/types/events/BalanceUpdatedEvent.ts b/src/lib/types/events/BalanceUpdatedEvent.ts index 99964d9..ef3c297 100644 --- a/src/lib/types/events/BalanceUpdatedEvent.ts +++ b/src/lib/types/events/BalanceUpdatedEvent.ts @@ -1,4 +1,4 @@ -import type { TrackedAsset } from "../business/TrackedAsset"; +import type { TrackedAsset } from "../domain/TrackedAsset"; import { EWalletEventKind } from "../enum/EWalletEventKind"; export class BalanceUpdatedEvent { diff --git a/src/lib/types/events/TransactionHistoryUpdatedEvent.ts b/src/lib/types/events/TransactionHistoryUpdatedEvent.ts new file mode 100644 index 0000000..fab951b --- /dev/null +++ b/src/lib/types/events/TransactionHistoryUpdatedEvent.ts @@ -0,0 +1,8 @@ +import type { TrackedAssetId } from "../primitives/TrackedAssetId"; +import { EWalletEventKind } from "../enum/EWalletEventKind"; + +/** Fired after optimistic activity is recorded or history is refreshed. */ +export class TransactionHistoryUpdatedEvent { + readonly kind = EWalletEventKind.TransactionHistoryUpdated as const; + constructor(public readonly trackedAssetId?: TrackedAssetId) {} +} diff --git a/src/lib/types/events/WalletDomainEvent.ts b/src/lib/types/events/WalletDomainEvent.ts index 334ae27..2c4d01b 100644 --- a/src/lib/types/events/WalletDomainEvent.ts +++ b/src/lib/types/events/WalletDomainEvent.ts @@ -1,6 +1,8 @@ import type { BalanceUpdatedEvent } from "./BalanceUpdatedEvent"; import type { RefreshBalanceRequestedEvent } from "./RefreshBalanceRequestedEvent"; +import type { TransactionHistoryUpdatedEvent } from "./TransactionHistoryUpdatedEvent"; export type WalletDomainEvent = | RefreshBalanceRequestedEvent - | BalanceUpdatedEvent; + | BalanceUpdatedEvent + | TransactionHistoryUpdatedEvent; diff --git a/src/lib/types/events/index.ts b/src/lib/types/events/index.ts index 6cd7054..521d638 100644 --- a/src/lib/types/events/index.ts +++ b/src/lib/types/events/index.ts @@ -1,3 +1,4 @@ export { BalanceUpdatedEvent } from "./BalanceUpdatedEvent"; export { RefreshBalanceRequestedEvent } from "./RefreshBalanceRequestedEvent"; +export { TransactionHistoryUpdatedEvent } from "./TransactionHistoryUpdatedEvent"; export type { WalletDomainEvent } from "./WalletDomainEvent"; diff --git a/src/ows/demoChains.ts b/src/ows/demoChains.ts index aadd7ea..68517a7 100644 --- a/src/ows/demoChains.ts +++ b/src/ows/demoChains.ts @@ -45,3 +45,15 @@ export function demoTxExplorerUrl( } return `${meta.blockExplorerUrl}/tx/${transactionHash}`; } + +/** Explorer address URL for a demo chain, or `null` when the chain is unknown. */ +export function demoAddressExplorerUrl( + chainId: EVMChainId, + address: string, +): string | null { + const meta = DEMO_CHAINS.find((chain) => chain.chainId === chainId); + if (!meta) { + return null; + } + return `${meta.blockExplorerUrl}/address/${address}`; +} diff --git a/src/relayer/RelayerCredentialsClient.ts b/src/relayer/RelayerCredentialsClient.ts index 0f610d9..a1d81a0 100644 --- a/src/relayer/RelayerCredentialsClient.ts +++ b/src/relayer/RelayerCredentialsClient.ts @@ -1,5 +1,5 @@ import type { COSEPublicKey } from "@1shotapi/ows-types"; -import { RELAYER_BASE_URL } from "./constants"; +import type { IConfigProvider } from "../lib/interfaces/utils/IConfigProvider"; import type { IRecoveredCredentialBlob, IRelayerCredentialsErrorBody, @@ -22,13 +22,10 @@ export class RelayerCredentialsError extends Error { /** * Thin REST client for 1Shot Relayer wallet credential endpoints * (`/wallet/credentials/*`, `/wallet/passkeys/register`). + * Base URL comes from {@link IConfigProvider} (iframe host → prod/dev relayer). */ export class RelayerCredentialsClient { - private readonly baseUrl: string; - - constructor(baseUrl: string = RELAYER_BASE_URL) { - this.baseUrl = baseUrl.replace(/\/$/, ""); - } + constructor(private readonly configProvider: IConfigProvider) {} async createChallenge(): Promise { return this.postJson( @@ -73,7 +70,8 @@ export class RelayerCredentialsClient { } private async postJson(path: string, body?: unknown): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { + const { relayerBaseUrl } = await this.configProvider.getConfig(); + const response = await fetch(`${relayerBaseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), diff --git a/src/relayer/constants.ts b/src/relayer/constants.ts deleted file mode 100644 index 17a797d..0000000 --- a/src/relayer/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * 1Shot Relayer base URL for wallet credential blob storage. - * Flip to production when ready: https://relayer.1shotapi.com - */ -export const RELAYER_BASE_URL = "https://relayer.1shotapi.dev"; diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index b87d821..96f1990 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -50,15 +50,22 @@ import { wrapSignerWithPasskeyPrompts } from "./wrapSignerWithPasskeyPrompts"; import { HardcodedKnownAssetRepository, LocalStorageTrackedAssetRepository, + BlockscoutAssetActivityRepository, OneshotRelayerRepository, } from "../lib/implementations/data"; import { + ConfigProvider, DemoChainsBlockchainProvider, EventBus, TransactionUtils, } from "../lib/implementations/utils"; import type { IEventBus } from "../lib/interfaces/utils"; -import type { KnownAsset, TrackedAsset } from "../lib/types/business"; +import type { IRecordSentActivityParams } from "../lib/interfaces/data"; +import type { + AssetActivity, + KnownAsset, + TrackedAsset, +} from "../lib/types/domain"; import { RefreshBalanceRequestedEvent } from "../lib/types/events"; import type { TrackedAssetId } from "../lib/types/primitives"; import { registerAddAssetRpc } from "./registerAddAsset"; @@ -80,6 +87,7 @@ import { useWalletSessionStore } from "./sessionStore"; const signerHolder: { current: OWSSigner | null } = { current: null }; const rpcHelperHolder: { current: RpcHelper | null } = { current: null }; +const configProvider = new ConfigProvider(); const blockchainProvider = new DemoChainsBlockchainProvider(); const eventBus: IEventBus = new EventBus(); const transactionUtils = new TransactionUtils(); @@ -89,25 +97,34 @@ const knownAssetRepository = new HardcodedKnownAssetRepository( const trackedAssetRepository = new LocalStorageTrackedAssetRepository( blockchainProvider, eventBus, + configProvider, ); -const oneshotRelayerRepository = new OneshotRelayerRepository({ - blockchain: blockchainProvider, - getSigner: () => { - if (!signerHolder.current) { - throw new Error("Signing Layer not ready"); - } - return signerHolder.current; - }, - getChainRpc: () => { - if (!rpcHelperHolder.current) { - throw new Error("RPC helper not ready"); - } - return rpcHelperHolder.current; +const assetActivityRepository = new BlockscoutAssetActivityRepository( + eventBus, + configProvider, +); +const oneshotRelayerRepository = new OneshotRelayerRepository( + { + blockchain: blockchainProvider, + getSigner: () => { + if (!signerHolder.current) { + throw new Error("Signing Layer not ready"); + } + return signerHolder.current; + }, + getChainRpc: () => { + if (!rpcHelperHolder.current) { + throw new Error("RPC helper not ready"); + } + return rpcHelperHolder.current; + }, }, -}); + configProvider, +); const credentialRepository = new CachedRelayerCredentialRepository({ - client: new RelayerCredentialsClient(), + client: new RelayerCredentialsClient(configProvider), + configProvider, getSigner: () => { if (!signerHolder.current) { throw new Error("Signing Layer not ready"); @@ -222,6 +239,14 @@ export type WalletContextValue = { address: EVMAccountAddress, ) => Promise; requestBalanceRefresh: (id?: TrackedAssetId) => void; + listAssetActivity: ( + owner: EVMAccountAddress, + asset: TrackedAsset, + limit?: number, + ) => Promise; + recordSentActivity: ( + params: IRecordSentActivityParams, + ) => Promise; /** * In-wallet submit (TransferTokensModal). Does not show host consent — * callers already collected amount/recipient. Routes to the relayer only. @@ -608,6 +633,24 @@ export function WalletProvider({ children }: { children: ReactNode }) { eventBus.emit(new RefreshBalanceRequestedEvent(id)); }, []); + const listAssetActivity = useCallback( + async ( + owner: EVMAccountAddress, + asset: TrackedAsset, + limit?: number, + ) => { + return assetActivityRepository.list({ owner, asset, limit }); + }, + [], + ); + + const recordSentActivity = useCallback( + async (params: IRecordSentActivityParams) => { + return assetActivityRepository.recordSent(params); + }, + [], + ); + /** * In-wallet send path: setup gate → relayer prepare/sign/broadcast → unlock. * Host EIP-1193 sends use SignHelper → approveAndSignTransaction instead. @@ -965,6 +1008,8 @@ export function WalletProvider({ children }: { children: ReactNode }) { getKnownAsset, resolveTrackedAsset, requestBalanceRefresh, + listAssetActivity, + recordSentActivity, sendTransaction, eventBus, openCreateBackup, @@ -991,6 +1036,8 @@ export function WalletProvider({ children }: { children: ReactNode }) { getKnownAsset, resolveTrackedAsset, requestBalanceRefresh, + listAssetActivity, + recordSentActivity, sendTransaction, openCreateBackup, openRestoreBackup, diff --git a/src/wallet/useWalletEvent.ts b/src/wallet/useWalletEvent.ts index eb6b957..928d05b 100644 --- a/src/wallet/useWalletEvent.ts +++ b/src/wallet/useWalletEvent.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import type { BalanceUpdatedEvent, RefreshBalanceRequestedEvent, + TransactionHistoryUpdatedEvent, } from "../lib/types/events"; import { useWallet } from "./WalletProvider"; @@ -24,3 +25,13 @@ export function useRefreshBalanceRequested( return eventBus.onRefreshBalanceRequested(handler); }, [eventBus, handler]); } + +/** Subscribe to TransactionHistoryUpdated for the lifetime of the component. */ +export function useTransactionHistoryUpdated( + handler: (event: TransactionHistoryUpdatedEvent) => void, +): void { + const { eventBus } = useWallet(); + useEffect(() => { + return eventBus.onTransactionHistoryUpdated(handler); + }, [eventBus, handler]); +}