From bba6fa4e5260d566a066e41145c634e23b9fd362 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Tue, 4 Aug 2026 11:48:15 +0700 Subject: [PATCH] feat(profiles): add historical snapshot option --- README.md | 6 ++++++ SKILLS.md | 6 ++++++ skills/formo-analytics/SKILL.md | 3 +++ src/commands/profiles.ts | 31 +++++++++++++++++++++++++++++++ test/commands/profiles.test.ts | 6 ++++++ 5 files changed, 52 insertions(+) diff --git a/README.md b/README.md index 1386c2c..8daaad2 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,12 @@ Fetch a single wallet profile by address or ENS name. | Option | Description | |---|---| | `--expand` | Comma-separated fields: `apps`, `chains`, `tokens`, `labels` | +| `--timestamp` | ISO-8601 timestamp; return the closest stored wallet-enrichment snapshot | ```bash formo profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 formo profiles get vitalik.eth --expand labels,chains +formo profiles get vitalik.eth --timestamp 2025-06-21T10:03:00Z ``` ### `profiles search` @@ -105,6 +107,7 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat |---|---| | `--address` | Filter by wallet address | | `--search` | Free-text search across address and identity fields | +| `--timestamp` | ISO-8601 timestamp; requires `--address` and returns the closest stored wallet-enrichment snapshot | | `--page` | Page number (1-indexed, default `1`) | | `--size` | Page size (default `100`, max `1000`) | | `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` | @@ -115,6 +118,7 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat ```bash formo profiles search --size 10 +formo profiles search --address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --timestamp 2025-06-21T10:03:00Z formo profiles search --order-by net_worth_usd --order-dir desc --size 5 formo profiles search --page 2 --size 20 formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 @@ -122,6 +126,8 @@ formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","valu formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20 ``` +With `--timestamp`, wallet-enrichment fields come from the stored snapshot closest to that instant. Exact ties select the later snapshot. Expanded chains, apps, and tokens come from the selected profiling batch, while project engagement fields, identity overrides, and labels remain current. `profiles search --timestamp` requires `--address`. + ### Lifecycle tuning (advanced) Both `profiles get` and `profiles search` accept optional flags to override the lifecycle stage thresholds used when computing `lifecycle`: diff --git a/SKILLS.md b/SKILLS.md index 94e24c8..c2ee1c6 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -51,6 +51,7 @@ Fetch the full profile for a single wallet by address or ENS name. ```bash formo profiles get
formo profiles get
--expand labels,chains,apps,tokens +formo profiles get
--timestamp 2025-06-21T10:03:00Z ``` | Argument | Description | @@ -60,6 +61,7 @@ formo profiles get
--expand labels,chains,apps,tokens | Option | Description | |---|---| | `--expand` | Comma-separated fields to include in full: `apps`, `chains`, `tokens`, `labels` | +| `--timestamp` | ISO-8601 timestamp; return the closest stored wallet-enrichment snapshot | **Examples:** ```bash @@ -84,6 +86,7 @@ formo profiles search [options] |---|---|---| | `--address` | `string` | Filter to a specific wallet address | | `--search` | `string` | Free-text search across address and identity fields | +| `--timestamp` | `string` | ISO-8601 timestamp; requires `--address` and returns the closest stored wallet-enrichment snapshot | | `--page` | `number` | Page number (1-indexed, default `1`) | | `--size` | `number` | Page size (default `100`, max `1000`) | | `--order-by` | see below | Field to sort by | @@ -99,6 +102,9 @@ formo profiles search [options] # First 10 profiles formo profiles search --size 10 +# Closest stored snapshot for one wallet +formo profiles search --address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --timestamp 2025-06-21T10:03:00Z + # Top 5 by net worth (descending) formo profiles search --order-by net_worth_usd --order-dir desc --size 5 diff --git a/skills/formo-analytics/SKILL.md b/skills/formo-analytics/SKILL.md index 1399e3e..d463114 100644 --- a/skills/formo-analytics/SKILL.md +++ b/skills/formo-analytics/SKILL.md @@ -72,6 +72,7 @@ Use a direct lookup for one address or ENS name. Use search for cohorts, orderin ```bash formo profiles get vitalik.eth --expand labels,chains,tokens +formo profiles get vitalik.eth --timestamp 2025-06-21T10:03:00Z formo profiles search --order-by net_worth_usd --order-dir desc --size 10 formo profiles search \ --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' \ @@ -82,6 +83,8 @@ Always use canonical filter paths: `users.` (e.g. `users.net_worth_us Profile updates, label changes, and wallet imports require `profiles:write`. Preserve pagination metadata and continue only while `has_more` is true. +Use `--timestamp ` with `profiles get` to return the stored wallet-enrichment snapshot closest to that instant. `profiles search --timestamp` supports the same lookup but requires `--address`. If two snapshots are equally close, the later snapshot is returned. Project engagement fields, identity overrides, and labels remain current. + ## Manage Formo resources Use CLI command groups for operational resources: diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index fbe4076..270e2ab 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -26,6 +26,7 @@ export interface LifecycleThresholdOptions { export interface GetProfileOptions extends LifecycleThresholdOptions { expand?: string + timestamp?: string } function addLifecycleThresholdParams( @@ -87,6 +88,11 @@ const lifecycleThresholdOptions = { .describe('Override lifecycle at-risk prior active days threshold'), } +const profileTimestampOption = z + .string() + .datetime({ offset: true }) + .optional() + export function getProfileRun( address: string, optionsOrExpand: GetProfileOptions | string = {}, @@ -99,6 +105,7 @@ export function getProfileRun( : optionsOrExpand const params: Record = {} if (options.expand) params.expand = options.expand + if (options.timestamp) params.timestamp = options.timestamp addLifecycleThresholdParams(params, options) return client.get(`/v0/profiles/${encodeURIComponent(address)}`, { params }) } @@ -113,6 +120,9 @@ profiles.command('get', { .string() .optional() .describe('Comma-separated list of fields to expand: apps,chains,tokens,labels'), + timestamp: profileTimestampOption.describe( + 'Return the wallet-enrichment snapshot closest to this ISO-8601 timestamp', + ), ...lifecycleThresholdOptions, }), examples: [ @@ -122,6 +132,11 @@ profiles.command('get', { options: { expand: 'labels,chains' }, description: 'Get profile with expanded labels and chains', }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { timestamp: '2025-06-21T10:03:00Z' }, + description: 'Get the closest stored wallet-enrichment snapshot', + }, ], hint: 'Requires profiles:read scope on your API key.', run({ args, options }) { @@ -132,6 +147,7 @@ profiles.command('get', { export interface SearchProfilesOptions extends LifecycleThresholdOptions { address?: string search?: string + timestamp?: string page?: number size?: number orderBy?: string @@ -326,12 +342,17 @@ export function parseSearchFilters(raw: string): unknown[] { } export function searchProfilesRun(options: SearchProfilesOptions) { + if (options.timestamp && !options.address) { + throw new Error('--timestamp requires --address') + } + requireApiKey() const client = createClient() const params: Record = {} if (options.address) params.address = options.address if (options.search) params.search = options.search + if (options.timestamp) params.timestamp = options.timestamp if (options.page !== undefined) params.page = options.page if (options.size !== undefined) params.size = options.size if (options.orderBy) params.order_by = options.orderBy @@ -361,6 +382,9 @@ profiles.command('search', { options: z.object({ address: z.string().optional().describe('Filter by wallet address'), search: z.string().optional().describe('Free-text search across address and identity fields'), + timestamp: profileTimestampOption.describe( + 'Return the closest wallet-enrichment snapshot; requires --address', + ), page: z.coerce .number() .int() @@ -423,6 +447,13 @@ profiles.command('search', { }), examples: [ { options: { size: 10 }, description: 'List first 10 profiles' }, + { + options: { + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + timestamp: '2025-06-21T10:03:00Z', + }, + description: 'Search the closest stored wallet-enrichment snapshot', + }, { options: { orderBy: 'net_worth_usd', orderDir: 'desc', size: 5 }, description: 'Top 5 profiles by net worth', diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index e9620d3..04b4d40 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -57,6 +57,12 @@ describe('commands/profiles', function () { searchProfilesRun({ filters: '{"field":"x"}' }), ).to.throw(/filters/); }); + + it('requires an address for historical snapshot searches', function () { + expect(() => + searchProfilesRun({ timestamp: '2025-06-21T10:03:00Z' }), + ).to.throw(/timestamp.*address/); + }); }); describe('updateProfileRun() — local validation', function () {