From 965748216810de228650374d52bd19a13c7c7870 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:15:22 +0200 Subject: [PATCH 1/3] 01a0dc6b - Offer hardcoded payment-link map filters --- README.md | 2 +- docs/handbook/endpoints.md | 6 +- docs/handbook/functions.md | 45 +++- e2e/map.spec.ts | 136 +++++++++- src/__tests__/map/filter-catalog.test.ts | 121 +++++++++ src/__tests__/map/place.test.ts | 16 ++ src/__tests__/map/routes.test.ts | 41 +-- src/__tests__/map/store.test.ts | 69 ++++- src/lib/map/filter-catalog.ts | 306 +++++++++++++++++++++++ src/lib/map/place.ts | 26 +- src/lib/map/sqlite.ts | 31 ++- src/lib/map/store.ts | 16 +- src/routes/map.ts | 8 +- 13 files changed, 763 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/map/filter-catalog.test.ts create mode 100644 src/lib/map/filter-catalog.ts diff --git a/README.md b/README.md index 52ded0019..cc672e822 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCryptoPay API -Map API. `GET /map/places` is the public list of places, each with a tech provider; `?origin=` limits that list to one origin. The list can also be limited by country, shopName (`SPAR` or `others`), blockchain, and asset; country is a stored column (the DFX country symbol), blockchain is a DFX payment-network name, and asset is the DFX asset name. `GET /map/filters` lists those filter values. `POST /map/places` adds a place once. `PUT /map/places` updates a place. `DELETE /map/places` removes a place. +Map API. `GET /map/places` is the public list of places, each with a tech provider; `?origin=` limits that list to one origin. The list can also be limited by country, shopName (`SPAR` or `others`), blockchain, and asset; country is a stored column (the DFX country symbol). A pin with support rows matches only those rows. A pin with none uses a hardcoded payment-link catalog: `21.gifts` is Lightning and BTC, and other pins use the DFX payment-link networks and assets. `GET /map/filters` lists shop names, stored countries, and that catalog plus any stored values. `POST /map/places` adds a place once. `PUT /map/places` updates a place. `DELETE /map/places` removes a place. The same `origin` and `externalId` returns the first pin and does not submit it again. When `BTCMAP_ACCESS_TOKEN` is set, a new pin is posted once to `https://api.btcmap.org/v4/place-submissions`. A missing token stores the pin and skips that call. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index a794ef68e..aee970a4a 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,14 +9,14 @@ ## Endpoint: GET /map/places -- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `country` is the stored DFX country symbol or null. `shopName` is the stored brand or null. `supports` is the stored blockchain and asset pairs, sorted. The caller id and payment methods are not included. Omitted `origin` is every pin. A present `origin` must be the same token as a stored origin. Optional `country`, `shopName` (`SPAR` or `others`), `blockchain`, and `asset` further restrict the store read. All predicates are AND and run before `limit`. `country` is a stored column, not inferred from an address. -- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. 400 `{ error: "Place origin is invalid" }` when `origin` is present but not a valid origin token. 400 `{ error: "Place country is invalid" }` when `country` is present but not an assigned ISO 3166-1 alpha-2 DFX country symbol. 400 `{ error: "Place shop name is invalid" }` when `shopName` is present and not `SPAR` or `others`. 400 `{ error: "Place support is invalid" }` when `blockchain` or `asset` is present but not a DFX payment-network name or DFX asset name. +- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `country` is the stored DFX country symbol or null. `shopName` is the stored brand or null. `supports` is the stored blockchain and asset pairs, sorted. The caller id and payment methods are not included. Omitted `origin` is every pin. A present `origin` must be the same token as a stored origin. Optional `country`, `shopName` (`SPAR` or `others`), `blockchain`, and `asset` further restrict the store read. All predicates are AND and run before `limit`. `country` is a stored column, not inferred from an address. `others` includes a pin with no brand. A pin that already has support rows matches only those rows. A pin with none matches the hardcoded payment-link catalog: origin `21gifts` or tech provider `21.gifts` matches Lightning and BTC only, and every other pin matches a catalog network, asset, or pair. +- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. 400 `{ error: "Place origin is invalid" }` when `origin` is present but not a valid origin token. 400 `{ error: "Place country is invalid" }` when `country` is present but not an assigned ISO 3166-1 alpha-2 DFX country symbol. 400 `{ error: "Place shop name is invalid" }` when `shopName` is present and not `SPAR` or `others`. 400 `{ error: "Place support is invalid" }` when `blockchain` or `asset` is present but not a payment-link or stored payment-network name, or not a payment-link asset name. - **Used by:** The OpenCryptoPay map. - **Auth:** none. Browser calls are allowed from any origin. ## Endpoint: GET /map/filters -- **Purpose:** Public list of filter values for the map. `shopNames` is always exactly `["SPAR", "others"]`. `countries` are the distinct stored ISO country codes, sorted. `blockchains` are the distinct support blockchains, sorted. `assets` are the distinct support assets, sorted. `?blockchain=` limits `assets` to that chain and still returns every blockchain. +- **Purpose:** Public list of filter values for the map. `shopNames` is always exactly `["SPAR", "others"]`. `countries` are the distinct stored ISO country codes, sorted. `blockchains` and `assets` are the hardcoded payment-link catalog plus distinct stored values, sorted. `?blockchain=` limits the top-level `assets` to that chain and still returns every blockchain. `techProviders` lists the full hardcoded offer for `DFX.swiss` and for `21.gifts` (Lightning and BTC only) and is not narrowed by `?blockchain=`. - **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a DFX payment-network name. - **Used by:** The OpenCryptoPay map filter UI. - **Auth:** none. Browser calls are allowed from any origin. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 200dc9e2f..fe850523f 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -16,11 +16,46 @@ ## Function: normalizeMapPlaceFilter -- **Purpose:** Validate the optional `country`, `shopName`, `blockchain`, and `asset` query strings for the public map. Omitted parameters stay off the filter. `country` is trimmed and uppercased. `shopName` is trimmed and must be `SPAR` or `others`. `blockchain` and `asset` are trimmed and not casefolded. +- **Purpose:** Validate the optional `country`, `shopName`, `blockchain`, and `asset` query strings for the public map. Omitted parameters stay off the filter. `country` is trimmed and uppercased. `shopName` is trimmed and must be `SPAR` or `others`. `blockchain` and `asset` are trimmed and not casefolded. A filter blockchain may be a stored payment-network name or a payment-link network such as BinancePay or KucoinPay. A filter asset may be a ticker, a prefixed ticker, or a catalog name such as ckBTC, USDC.e, or USDbC. A support body still uses the stricter stored-pair rules. - **Inputs:** Four optional raw query strings. - **Returns / side effects:** `{ ok: true, value }` with the present filter fields, or `{ ok: false, error }` with `Place country is invalid`, `Place shop name is invalid`, or `Place support is invalid`. No I/O. - **Used by:** `mapRoutes` on `GET /map/places` and `GET /map/filters`. +## Function: isCatalogBlockchain + +- **Purpose:** Report whether a trimmed blockchain query is in the hardcoded payment-link catalog. Test networks, Plasma, and names that are not payment-link networks are excluded. BinancePay and KucoinPay are included. +- **Inputs:** A trimmed blockchain string. +- **Returns / side effects:** True or false. No I/O. +- **Used by:** `normalizeMapPlaceFilter` when a blockchain query is not a stored payment-network name. + +## Function: isCatalogAsset + +- **Purpose:** Report whether a trimmed asset query is in the hardcoded payment-link catalog. This includes names that are not plain tickers, such as ckBTC, USDC.e, and USDbC. +- **Inputs:** A trimmed asset string. +- **Returns / side effects:** True or false. No I/O. +- **Used by:** `normalizeMapPlaceFilter` when an asset query is not a ticker or a prefixed ticker. + +## Function: matchesUnstatedPayment + +- **Purpose:** Decide whether a pin with no stored support rows matches a blockchain or asset filter. Origin `21gifts` or tech provider `21.gifts` matches Lightning and BTC only. Every other pin matches a catalog network, a catalog asset, or a catalog pair. An omitted filter matches. +- **Inputs:** Stored origin, stored tech provider, and the optional blockchain and asset from the query. +- **Returns / side effects:** True when the pin stays in the list. No I/O. +- **Used by:** `MemoryMapPlaceStore.list`. The SQLite driver uses `unstatedPaymentSql` for the same rule. + +## Function: unstatedPaymentSql + +- **Purpose:** Build the SQLite predicate for a blockchain or asset filter. Stored support rows still match. A pin with no support rows matches only when the hardcoded catalog offers that network or asset for its origin and tech provider. A request outside the catalog returns null so only stored rows match. +- **Inputs:** Optional blockchain and asset query values. +- **Returns / side effects:** A parenthesised SQL fragment and its bound parameters, or null. No I/O. +- **Used by:** `SqliteMapPlaceStore.list`. + +## Function: publicFilterResponse + +- **Purpose:** Merge distinct stored filter values with the hardcoded payment-link catalog for `GET /map/filters`. `?blockchain=` limits the top-level asset list. `techProviders` always contains the full `DFX.swiss` offer and the `21.gifts` offer of Lightning and BTC. +- **Inputs:** Stored countries, blockchains, and assets, plus an optional blockchain query. +- **Returns / side effects:** `{ shopNames, countries, blockchains, assets, techProviders }`. No I/O. +- **Used by:** `mapRoutes` on `GET /map/filters`. + ## Function: normalizeMapPlaceKey - **Purpose:** Validate origin and external id for deleting a map pin. Extra JSON fields are ignored. The body does not need coordinates or a name. @@ -39,13 +74,13 @@ - **Purpose:** In-memory map store with the same insert-once, upsert, and delete rules as SQLite. A repeat of origin plus external id on insert returns the first row unchanged, including its tech provider, country, shop name, and supports. - **Inputs:** `insertIfNew` and `upsert` take a validated pin. `deleteByKey` takes origin and external id. `list` takes a positive limit and an optional filter object (`origin`, `country`, `shopName`, `blockchain`, `asset`). `filters` takes an optional blockchain. -- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `deleteByKey` is true only when a row was removed. `list` is newest first, then id descending. Predicates are AND and apply before sort and limit. `shopName` `SPAR` matches that stored brand; `others` matches a non-null brand other than SPAR. Blockchain and asset must occur on the same support row. `filters` returns distinct stored countries, blockchains, and assets; shop names are not read. `close` drops the rows. +- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `deleteByKey` is true only when a row was removed. `list` is newest first, then id descending. Predicates are AND and apply before sort and limit. `shopName` `SPAR` matches that stored brand; `others` matches every other pin, including a missing brand. A pin with support rows matches a blockchain or asset only on those rows, and a pair must be the same row. A pin with no support rows matches the hardcoded payment-link catalog. `filters` returns distinct stored countries, blockchains, and assets; shop names are not read. `close` drops the rows. - **Used by:** Unit tests. The process uses `SqliteMapPlaceStore`. ## Function: SqliteMapPlaceStore - **Purpose:** SQLite driver for map pins. The schema is `MAP_PLACE_SCHEMA_SQL`. Opening an older file adds `tech_provider`, `country`, and `shop_name` when those columns are missing, creates `map_place_support` and its indexes if missing, and backfills `shop_name` to SPAR for origin `spar` when that column is null. A conflicting insert does not overwrite the first row. -- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert` and `deleteByKey` use the same origin and external id pair as insert. `list` takes a positive limit and an optional filter object. Predicates are AND and apply before sort and limit. SQLite binds every value. `filters` takes an optional blockchain. +- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert` and `deleteByKey` use the same origin and external id pair as insert. `list` takes a positive limit and an optional filter object. Predicates are AND and apply before sort and limit. A pin with no support rows uses the same hardcoded payment-link catalog as the memory store. SQLite binds every value. `filters` takes an optional blockchain. - **Returns / side effects:** Opens the file, enables foreign keys, applies the schema, and implements `MapPlaceStore`. Present filter fields are AND-ed before the sort and the limit. Deleting a pin deletes its support rows. `close` closes the handle. - **Used by:** The process entrypoint. The HTTP end-to-end run exercises it. @@ -65,9 +100,9 @@ ## Function: mapRoutes -- **Purpose:** `GET /map/places` is public and may take `origin`, `country`, `shopName`, `blockchain`, and `asset` to restrict the list. `GET /map/filters` is public and returns the shop name tokens plus distinct countries, blockchains, and assets. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. +- **Purpose:** `GET /map/places` is public and may take `origin`, `country`, `shopName`, `blockchain`, and `asset` to restrict the list. `GET /map/filters` is public and returns the shop name tokens, distinct countries, the payment-link catalog merged with stored blockchains and assets, and `techProviders`. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. - **Inputs:** Store, optional ingest token, environment, and fetch. -- **Returns / side effects:** GET `/places` returns `{ places }`. GET `/filters` returns `{ shopNames, countries, blockchains, assets }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. +- **Returns / side effects:** GET `/places` returns `{ places }`. GET `/filters` returns `{ shopNames, countries, blockchains, assets, techProviders }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. - **Used by:** `createApp`, mounted at `/map`. ## Function: healthRoutes diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 9cb617e8f..73156a8fc 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -410,7 +410,10 @@ test('Function: SqliteMapPlaceStore — delete removes the pin and its support f assets: string[]; }; expect(afterFilterJson.blockchains).not.toContain('Plasma'); - expect(afterFilterJson.assets).not.toContain('ONDO'); + const plasmaFilters = await request.get('/map/filters?blockchain=Plasma'); + expect(plasmaFilters.status()).toBe(200); + const plasmaJson = (await plasmaFilters.json()) as { assets: string[] }; + expect(plasmaJson.assets).not.toContain('ONDO'); const afterDelete = (await (await request.get('/map/places')).json()) as { places: Array<{ name: string }>; @@ -425,6 +428,137 @@ test('Function: SqliteMapPlaceStore — delete removes the pin and its support f expect(keptJson.places.some((row) => row.name === 'Support Delete Pin')).toBe(false); }); +test('Function: unstatedPaymentSql — a pin without supports uses the payment-link catalog', async ({ + request, +}) => { + const dfx = { + origin: 'dfx', + externalId: 'e2e-catalog-dfx', + name: 'Catalog DFX Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + const gifts = { + origin: '21gifts', + externalId: 'e2e-catalog-gifts', + name: 'Catalog Gifts Pin', + lat: 14.62, + lon: 120.96, + category: 'shopping', + techProvider: '21.gifts', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: dfx, + }) + ).status(), + ).toBe(201); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: gifts, + }) + ).status(), + ).toBe(201); + + const names = async (query: string): Promise => { + const response = await request.get(`/map/places?${query}`); + expect(response.status()).toBe(200); + const json = (await response.json()) as { places: Array<{ name: string }> }; + return json.places.map((row) => row.name); + }; + + expect(await names('blockchain=Ethereum')).toContain('Catalog DFX Pin'); + expect(await names('blockchain=Ethereum')).not.toContain('Catalog Gifts Pin'); + expect(await names('blockchain=Lightning&asset=BTC')).toEqual( + expect.arrayContaining(['Catalog DFX Pin', 'Catalog Gifts Pin']), + ); + expect(await names('blockchain=Ethereum&asset=BTC')).not.toContain('Catalog DFX Pin'); + expect(await names('blockchain=Ethereum&asset=BTC')).not.toContain('Catalog Gifts Pin'); + expect(await names('blockchain=BinancePay')).toContain('Catalog DFX Pin'); + expect(await names('blockchain=BinancePay')).not.toContain('Catalog Gifts Pin'); + expect(await names('asset=ckBTC')).toContain('Catalog DFX Pin'); + expect(await names('asset=ckBTC')).not.toContain('Catalog Gifts Pin'); + expect(await names('asset=USDC.e')).toContain('Catalog DFX Pin'); + expect(await names('blockchain=Plasma')).not.toContain('Catalog DFX Pin'); + expect(await names('shopName=others')).toContain('Catalog DFX Pin'); + expect(await names('shopName=others')).toContain('Catalog Gifts Pin'); + expect(await names('shopName=SPAR')).not.toContain('Catalog DFX Pin'); +}); + +test('Function: matchesUnstatedPayment — 21.gifts stays on Lightning and BTC', async ({ + request, +}) => { + const listed = await request.get('/map/places?blockchain=Lightning&asset=BTC'); + expect(listed.status()).toBe(200); + const json = (await listed.json()) as { places: Array<{ name: string }> }; + expect(json.places.some((row) => row.name === 'Catalog Gifts Pin')).toBe(true); + const hidden = await request.get('/map/places?asset=ZCHF'); + expect(hidden.status()).toBe(200); + const hiddenJson = (await hidden.json()) as { places: Array<{ name: string }> }; + expect(hiddenJson.places.some((row) => row.name === 'Catalog Gifts Pin')).toBe(false); +}); + +test('Function: publicFilterResponse — filters include the payment-link catalog', async ({ + request, +}) => { + const filters = await request.get('/map/filters'); + expect(filters.status()).toBe(200); + const json = (await filters.json()) as { + blockchains: string[]; + assets: string[]; + techProviders: Array<{ + name: string; + blockchains: string[]; + assets: string[]; + pairs: Array<{ blockchain: string; asset: string }>; + }>; + }; + expect(json.blockchains).toContain('Lightning'); + expect(json.blockchains).toContain('BinancePay'); + expect(json.blockchains).not.toContain('Sepolia'); + expect(json.assets).toContain('ckBTC'); + expect(json.assets).toContain('USDC.e'); + expect(json.assets).toContain('USDbC'); + const dfx = json.techProviders.find((provider) => provider.name === 'DFX.swiss'); + const gifts = json.techProviders.find((provider) => provider.name === '21.gifts'); + expect(dfx?.pairs).toContainEqual({ blockchain: 'Ethereum', asset: 'ZCHF' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Ethereum', asset: 'BTC' }); + expect(dfx?.blockchains).not.toContain('Plasma'); + expect(gifts).toEqual({ + name: '21.gifts', + blockchains: ['Lightning'], + assets: ['BTC'], + pairs: [{ blockchain: 'Lightning', asset: 'BTC' }], + }); + const ethereum = await request.get('/map/filters?blockchain=Ethereum'); + expect(ethereum.status()).toBe(200); + const ethereumJson = (await ethereum.json()) as { assets: string[]; blockchains: string[] }; + expect(ethereumJson.assets).toContain('ZCHF'); + expect(ethereumJson.assets).not.toContain('BTC'); + expect(ethereumJson.blockchains).toContain('Polygon'); +}); + +test('Function: isCatalogBlockchain — BinancePay is a filter and Kraken is not', async ({ + request, +}) => { + expect((await request.get('/map/places?blockchain=BinancePay')).status()).toBe(200); + expect((await request.get('/map/places?blockchain=Kraken')).status()).toBe(400); + expect((await request.get('/map/filters?blockchain=KucoinPay')).status()).toBe(200); + expect((await request.get('/map/filters?blockchain=Frick')).status()).toBe(400); +}); + +test('Function: isCatalogAsset — catalog asset names are filter queries', async ({ request }) => { + expect((await request.get('/map/places?asset=ckBTC')).status()).toBe(200); + expect((await request.get('/map/places?asset=USDC.e')).status()).toBe(200); + expect((await request.get('/map/places?asset=USDbC')).status()).toBe(200); + expect((await request.get('/map/places?asset=Ethereum/ZCHF')).status()).toBe(400); +}); + test('Function: SqliteMapPlaceStore — opening backfills only a null SPAR shop name', () => { const stdout = execFileSync('bun', ['e2e/sqlite-backfill.ts'], { encoding: 'utf8' }); expect(stdout.trim()).toBe('ok'); diff --git a/src/__tests__/map/filter-catalog.test.ts b/src/__tests__/map/filter-catalog.test.ts new file mode 100644 index 000000000..40e518850 --- /dev/null +++ b/src/__tests__/map/filter-catalog.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { + isCatalogAsset, + isCatalogBlockchain, + matchesUnstatedPayment, + publicFilterResponse, + unstatedPaymentSql, +} from '@/lib/map/filter-catalog'; + +describe('payment-link filter catalog', () => { + it('names the payment-link networks and assets, not test or inactive ones', () => { + expect(isCatalogBlockchain('Ethereum')).toBe(true); + expect(isCatalogBlockchain('BinancePay')).toBe(true); + expect(isCatalogBlockchain('KucoinPay')).toBe(true); + expect(isCatalogBlockchain('Zano')).toBe(true); + expect(isCatalogBlockchain('Lightning')).toBe(true); + expect(isCatalogBlockchain('Plasma')).toBe(false); + expect(isCatalogBlockchain('Sepolia')).toBe(false); + expect(isCatalogBlockchain('Arkade')).toBe(false); + expect(isCatalogBlockchain('Kraken')).toBe(false); + + expect(isCatalogAsset('ZCHF')).toBe(true); + expect(isCatalogAsset('ckBTC')).toBe(true); + expect(isCatalogAsset('USDC.e')).toBe(true); + expect(isCatalogAsset('USDbC')).toBe(true); + expect(isCatalogAsset('dEURO')).toBe(true); + expect(isCatalogAsset('XPL')).toBe(false); + expect(isCatalogAsset('ckbtc')).toBe(false); + }); + + it('matches an unstated pin from the catalog for its tech provider', () => { + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', undefined, undefined)).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Ethereum', undefined)).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Ethereum', 'ZCHF')).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Ethereum', 'BTC')).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Plasma', undefined)).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'BinancePay', undefined)).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', undefined, 'ckBTC')).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', undefined, 'AAAA')).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Lightning', 'BTC')).toBe(true); + + expect(matchesUnstatedPayment('21gifts', 'DFX.swiss', 'Ethereum', undefined)).toBe(false); + expect(matchesUnstatedPayment('dfx', '21.gifts', 'Ethereum', undefined)).toBe(false); + expect(matchesUnstatedPayment('21gifts', '21.gifts', 'Lightning', undefined)).toBe(true); + expect(matchesUnstatedPayment('21gifts', '21.gifts', undefined, 'BTC')).toBe(true); + expect(matchesUnstatedPayment('21gifts', '21.gifts', 'Lightning', 'BTC')).toBe(true); + expect(matchesUnstatedPayment('21gifts', '21.gifts', 'Lightning', 'ZCHF')).toBe(false); + expect(matchesUnstatedPayment('21gifts', '21.gifts', undefined, 'ZCHF')).toBe(false); + expect(matchesUnstatedPayment('21gifts', '21.gifts', undefined, 'ckBTC')).toBe(false); + }); + + it('builds a stored-row predicate only when the catalog cannot match', () => { + expect(unstatedPaymentSql()).toBeNull(); + expect(unstatedPaymentSql('Plasma')).toBeNull(); + expect(unstatedPaymentSql('Ethereum', 'BTC')).toBeNull(); + expect(unstatedPaymentSql(undefined, 'AAAA')).toBeNull(); + + const chain = unstatedPaymentSql('Ethereum'); + expect(chain?.params).toEqual(['Ethereum', '0', '1']); + expect(chain?.sql).toContain('NOT EXISTS'); + expect(chain?.sql).toContain("tech_provider = '21.gifts'"); + expect(chain?.sql).toContain("origin = '21gifts'"); + + expect(unstatedPaymentSql('Lightning', 'BTC')?.params).toEqual(['Lightning', 'BTC', '1', '1']); + expect(unstatedPaymentSql(undefined, 'ckBTC')?.params).toEqual(['ckBTC', '0', '1']); + expect(unstatedPaymentSql(undefined, 'BTC')?.params).toEqual(['BTC', '1', '1']); + expect(unstatedPaymentSql('BinancePay')?.params).toEqual(['BinancePay', '0', '1']); + expect(unstatedPaymentSql('Ethereum', 'ZCHF')?.params).toEqual(['Ethereum', 'ZCHF', '0', '1']); + }); + + it('unions stored filter values with the catalog and keeps provider offers whole', () => { + const merged = publicFilterResponse({ + countries: ['CH'], + blockchains: ['Plasma'], + assets: ['ONDO', 'ZCHF'], + }); + expect(merged.shopNames).toEqual(['SPAR', 'others']); + expect(merged.countries).toEqual(['CH']); + expect(merged.blockchains).toContain('Plasma'); + expect(merged.blockchains).toContain('Lightning'); + expect(merged.blockchains).not.toContain('Sepolia'); + expect(merged.blockchains[0]).toBe('Arbitrum'); + expect(merged.assets.filter((asset) => asset === 'ZCHF')).toHaveLength(1); + expect(merged.assets).toContain('ONDO'); + expect(merged.assets).toContain('BTC'); + expect(merged.assets).toContain('ckBTC'); + + const ethereum = publicFilterResponse( + { countries: [], blockchains: ['Plasma', 'Ethereum'], assets: ['AAAA'] }, + 'Ethereum', + ); + expect(ethereum.assets).toContain('AAAA'); + expect(ethereum.assets).toContain('ZCHF'); + expect(ethereum.assets).toContain('ONDO'); + expect(ethereum.assets).not.toContain('BTC'); + expect(ethereum.blockchains).toContain('Plasma'); + + const plasma = publicFilterResponse( + { countries: [], blockchains: ['Plasma'], assets: ['ONDO'] }, + 'Plasma', + ); + expect(plasma.assets).toEqual(['ONDO']); + + const dfx = merged.techProviders.find((provider) => provider.name === 'DFX.swiss'); + const gifts = merged.techProviders.find((provider) => provider.name === '21.gifts'); + expect(dfx?.blockchains).not.toContain('Plasma'); + expect(dfx?.blockchains).not.toContain('Sepolia'); + expect(dfx?.blockchains).toContain('BinancePay'); + expect(dfx?.blockchains).toContain('Zano'); + expect(dfx?.assets).not.toContain('XPL'); + expect(dfx?.pairs).toContainEqual({ blockchain: 'Ethereum', asset: 'ZCHF' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Ethereum', asset: 'BTC' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Arkade', asset: 'BTC' }); + expect(gifts).toEqual({ + name: '21.gifts', + blockchains: ['Lightning'], + assets: ['BTC'], + pairs: [{ blockchain: 'Lightning', asset: 'BTC' }], + }); + }); +}); diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 503edee21..28fbac008 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -430,6 +430,22 @@ describe('normalizeMapPlaceFilter', () => { ok: true, value: { shopName: 'others' }, }); + expect(normalizeMapPlaceFilter(undefined, undefined, ' BinancePay ', ' ckBTC ')).toEqual({ + ok: true, + value: { blockchain: 'BinancePay', asset: 'ckBTC' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'KucoinPay', 'USDC.e')).toEqual({ + ok: true, + value: { blockchain: 'KucoinPay', asset: 'USDC.e' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'USDbC')).toEqual({ + ok: true, + value: { asset: 'USDbC' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'ZCHF')).toEqual({ + ok: true, + value: { asset: 'ZCHF' }, + }); }); it('rejects a bad country, shop name, blockchain, or asset query', () => { diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index 95fddbb95..04f07b308 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createApp } from '@/server'; +import { publicFilterResponse } from '@/lib/map/filter-catalog'; import { MemoryMapPlaceStore } from '@/lib/map/store'; +import { createApp } from '@/server'; const body = { origin: 'dfx', @@ -41,12 +42,9 @@ describe('map routes', () => { expect(one.status).toBe(200); const filters = await api.request('/map/filters'); expect(filters.status).toBe(200); - expect(await filters.json()).toEqual({ - shopNames: ['SPAR', 'others'], - countries: [], - blockchains: [], - assets: [], - }); + expect(await filters.json()).toEqual( + publicFilterResponse({ countries: [], blockchains: [], assets: [] }), + ); }); it('filters GET /map/places by origin and rejects SPAR', async () => { @@ -527,20 +525,25 @@ describe('map routes', () => { expect(created.status).toBe(201); const filters = await api.request('/map/filters'); expect(filters.status).toBe(200); - expect(await filters.json()).toEqual({ - shopNames: ['SPAR', 'others'], - countries: ['CH'], - blockchains: ['Ethereum', 'Polygon'], - assets: ['ETH', 'ZCHF'], - }); + expect(await filters.json()).toEqual( + publicFilterResponse({ + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ETH', 'ZCHF'], + }), + ); const ethereum = await api.request('/map/filters?blockchain=Ethereum'); expect(ethereum.status).toBe(200); - expect(await ethereum.json()).toEqual({ - shopNames: ['SPAR', 'others'], - countries: ['CH'], - blockchains: ['Ethereum', 'Polygon'], - assets: ['ZCHF'], - }); + expect(await ethereum.json()).toEqual( + publicFilterResponse( + { + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ZCHF'], + }, + 'Ethereum', + ), + ); const bad = await api.request('/map/filters?blockchain=ethereum'); expect(bad.status).toBe(400); expect(await bad.json()).toEqual({ error: 'Place support is invalid' }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index cc6d20732..6288783d3 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -46,8 +46,14 @@ describe('MemoryMapPlaceStore', () => { expect(listed.map((row) => row.externalId)).toEqual(['store-2', 'store-1']); expect(store.list(1)).toHaveLength(1); expect(store.list(10, {}).map((row) => row.externalId)).toEqual(['store-2', 'store-1']); - expect(store.list(10, { blockchain: 'Ethereum' })).toHaveLength(0); - expect(store.list(10, { asset: 'ZCHF' })).toHaveLength(0); + expect(store.list(10, { blockchain: 'Ethereum' }).map((row) => row.externalId)).toEqual([ + 'store-2', + 'store-1', + ]); + expect(store.list(10, { asset: 'ZCHF' }).map((row) => row.externalId)).toEqual([ + 'store-2', + 'store-1', + ]); store.close(); expect(store.list(10)).toHaveLength(0); expect(later.created).toBe(true); @@ -151,7 +157,7 @@ describe('MemoryMapPlaceStore', () => { expect(listed[0]?.country).toBe('CH'); }); - it('lists SPAR shop names and others without null brands', () => { + it('lists SPAR shop names and treats a missing brand as others', () => { let tick = 0; const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); store.insertIfNew({ ...input, externalId: 'spar-brand', shopName: 'SPAR' }); @@ -160,8 +166,61 @@ describe('MemoryMapPlaceStore', () => { const spar = store.list(10, { shopName: 'SPAR' }); expect(spar.map((row) => row.externalId)).toEqual(['spar-brand']); const others = store.list(10, { shopName: 'others' }); - expect(others.map((row) => row.externalId)).toEqual(['migros']); - expect(others.every((row) => row.shopName !== null && row.shopName !== 'SPAR')).toBe(true); + expect(others.map((row) => row.externalId)).toEqual(['none', 'migros']); + expect(others.every((row) => row.shopName !== 'SPAR')).toBe(true); + }); + + it('uses the payment-link catalog when a pin has no support rows', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ ...input, externalId: 'dfx-open' }); + store.insertIfNew({ + ...input, + origin: '21gifts', + externalId: 'gifts-open', + techProvider: '21.gifts', + }); + store.insertIfNew({ + ...input, + origin: '21gifts', + externalId: 'origin-only', + }); + store.insertIfNew({ + ...input, + externalId: 'label-only', + techProvider: '21.gifts', + }); + store.insertIfNew({ + ...input, + externalId: 'stated', + supports: [{ blockchain: 'Polygon', asset: 'ZCHF' }], + }); + const ids = (filter: { + blockchain?: string; + asset?: string; + shopName?: 'SPAR' | 'others'; + }): string[] => store.list(10, filter).map((row) => row.externalId); + + expect(ids({ blockchain: 'Ethereum' }).sort()).toEqual(['dfx-open']); + expect(ids({ blockchain: 'Lightning', asset: 'BTC' }).sort()).toEqual([ + 'dfx-open', + 'gifts-open', + 'label-only', + 'origin-only', + ]); + expect(ids({ blockchain: 'Ethereum', asset: 'BTC' })).toEqual([]); + expect(ids({ blockchain: 'Plasma' })).toEqual([]); + expect(ids({ blockchain: 'BinancePay' }).sort()).toEqual(['dfx-open']); + expect(ids({ asset: 'ckBTC' }).sort()).toEqual(['dfx-open']); + expect(ids({ blockchain: 'Bitcoin' }).sort()).toEqual(['dfx-open']); + expect(ids({ blockchain: 'Polygon', asset: 'ZCHF' }).sort()).toEqual(['dfx-open', 'stated']); + expect(ids({ shopName: 'SPAR' })).toEqual([]); + expect(ids({ shopName: 'others' }).sort()).toEqual([ + 'dfx-open', + 'gifts-open', + 'label-only', + 'origin-only', + 'stated', + ]); }); it('requires blockchain and asset to match the same support row', () => { diff --git a/src/lib/map/filter-catalog.ts b/src/lib/map/filter-catalog.ts new file mode 100644 index 000000000..85dfa7ba1 --- /dev/null +++ b/src/lib/map/filter-catalog.ts @@ -0,0 +1,306 @@ +/** + * Temporary payment-link filter catalog. + * Networks are those offered for payment links, without test networks and + * without Plasma. Assets are the sellable names on those networks. + * 21.gifts is Lightning and BTC only. Replace this module when the offer + * is read from the payment service. + */ + +const GIFTS_ORIGIN = '21gifts'; +const GIFTS_TECH_PROVIDER = '21.gifts'; +const GIFTS_BLOCKCHAIN = 'Lightning'; +const GIFTS_ASSET = 'BTC'; + +const DFX_BLOCKCHAINS: readonly string[] = [ + 'Arbitrum', + 'Base', + 'BinancePay', + 'BinanceSmartChain', + 'Bitcoin', + 'Cardano', + 'Ethereum', + 'Firo', + 'Gnosis', + 'InternetComputer', + 'KucoinPay', + 'Lightning', + 'Monero', + 'Optimism', + 'Polygon', + 'Solana', + 'Spark', + 'Tron', + 'Zano', +]; + +const DFX_PAIRS: readonly { blockchain: string; asset: string }[] = [ + { blockchain: 'Arbitrum', asset: 'AAVE' }, + { blockchain: 'Arbitrum', asset: 'ARB' }, + { blockchain: 'Arbitrum', asset: 'ETH' }, + { blockchain: 'Arbitrum', asset: 'GMX' }, + { blockchain: 'Arbitrum', asset: 'LINK' }, + { blockchain: 'Arbitrum', asset: 'UNI' }, + { blockchain: 'Arbitrum', asset: 'USDC' }, + { blockchain: 'Arbitrum', asset: 'USDC.e' }, + { blockchain: 'Arbitrum', asset: 'USDT' }, + { blockchain: 'Arbitrum', asset: 'WBTC' }, + { blockchain: 'Arbitrum', asset: 'dEURO' }, + { blockchain: 'Base', asset: 'DEPS' }, + { blockchain: 'Base', asset: 'ETH' }, + { blockchain: 'Base', asset: 'USDC' }, + { blockchain: 'Base', asset: 'USDbC' }, + { blockchain: 'Base', asset: 'dEURO' }, + { blockchain: 'BinanceSmartChain', asset: 'AAVE' }, + { blockchain: 'BinanceSmartChain', asset: 'BNB' }, + { blockchain: 'BinanceSmartChain', asset: 'BTCB' }, + { blockchain: 'BinanceSmartChain', asset: 'ETH' }, + { blockchain: 'BinanceSmartChain', asset: 'USDC' }, + { blockchain: 'BinanceSmartChain', asset: 'USDT' }, + { blockchain: 'Bitcoin', asset: 'BTC' }, + { blockchain: 'Cardano', asset: 'ADA' }, + { blockchain: 'Ethereum', asset: 'AAVE' }, + { blockchain: 'Ethereum', asset: 'APE' }, + { blockchain: 'Ethereum', asset: 'ARB' }, + { blockchain: 'Ethereum', asset: 'BBTC' }, + { blockchain: 'Ethereum', asset: 'CRV' }, + { blockchain: 'Ethereum', asset: 'DEPS' }, + { blockchain: 'Ethereum', asset: 'EDLC' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'EURC' }, + { blockchain: 'Ethereum', asset: 'FPS' }, + { blockchain: 'Ethereum', asset: 'LINK' }, + { blockchain: 'Ethereum', asset: 'ONDO' }, + { blockchain: 'Ethereum', asset: 'POL' }, + { blockchain: 'Ethereum', asset: 'QNT' }, + { blockchain: 'Ethereum', asset: 'REALU' }, + { blockchain: 'Ethereum', asset: 'SNX' }, + { blockchain: 'Ethereum', asset: 'USDC' }, + { blockchain: 'Ethereum', asset: 'USDT' }, + { blockchain: 'Ethereum', asset: 'WBTC' }, + { blockchain: 'Ethereum', asset: 'WFPS' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'dEURO' }, + { blockchain: 'Ethereum', asset: 'kBTC' }, + { blockchain: 'Ethereum', asset: 'nDEPS' }, + { blockchain: 'Firo', asset: 'FIRO' }, + { blockchain: 'Gnosis', asset: 'xDAI' }, + { blockchain: 'InternetComputer', asset: 'ICP' }, + { blockchain: 'InternetComputer', asset: 'VCHF' }, + { blockchain: 'InternetComputer', asset: 'VEUR' }, + { blockchain: 'InternetComputer', asset: 'ckBTC' }, + { blockchain: 'Lightning', asset: 'BTC' }, + { blockchain: 'Monero', asset: 'XMR' }, + { blockchain: 'Optimism', asset: 'ETH' }, + { blockchain: 'Optimism', asset: 'USDC' }, + { blockchain: 'Optimism', asset: 'USDT' }, + { blockchain: 'Optimism', asset: 'WBTC' }, + { blockchain: 'Optimism', asset: 'dEURO' }, + { blockchain: 'Polygon', asset: 'DEPS' }, + { blockchain: 'Polygon', asset: 'DGC' }, + { blockchain: 'Polygon', asset: 'DSC' }, + { blockchain: 'Polygon', asset: 'EURS' }, + { blockchain: 'Polygon', asset: 'POL' }, + { blockchain: 'Polygon', asset: 'SAND' }, + { blockchain: 'Polygon', asset: 'USDC' }, + { blockchain: 'Polygon', asset: 'USDC.e' }, + { blockchain: 'Polygon', asset: 'USDT' }, + { blockchain: 'Polygon', asset: 'WBTC' }, + { blockchain: 'Polygon', asset: 'WFPS' }, + { blockchain: 'Polygon', asset: 'dEURO' }, + { blockchain: 'Solana', asset: 'SOL' }, + { blockchain: 'Solana', asset: 'USDC' }, + { blockchain: 'Solana', asset: 'USDT' }, + { blockchain: 'Spark', asset: 'BTC' }, + { blockchain: 'Tron', asset: 'TRX' }, + { blockchain: 'Tron', asset: 'USDT' }, +]; + +const CHAIN_SET: ReadonlySet = new Set(DFX_BLOCKCHAINS); +const ASSET_SET: ReadonlySet = new Set(DFX_PAIRS.map((pair) => pair.asset)); +const PAIR_KEYS: ReadonlySet = new Set( + DFX_PAIRS.map((pair) => `${pair.blockchain}\0${pair.asset}`), +); + +export type MapFilterTechProvider = { + name: string; + blockchains: string[]; + assets: string[]; + pairs: { blockchain: string; asset: string }[]; +}; + +export type PublicMapFilters = { + shopNames: ['SPAR', 'others']; + countries: string[]; + blockchains: string[]; + assets: string[]; + techProviders: MapFilterTechProvider[]; +}; + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function offerFlags( + blockchain: string | undefined, + asset: string | undefined, +): { gifts: boolean; dfx: boolean } { + const gifts = + (blockchain === undefined || blockchain === GIFTS_BLOCKCHAIN) && + (asset === undefined || asset === GIFTS_ASSET) && + (blockchain !== undefined || asset !== undefined); + let dfx = false; + if (blockchain !== undefined && asset !== undefined) { + dfx = PAIR_KEYS.has(`${blockchain}\0${asset}`); + } else if (blockchain !== undefined) { + dfx = CHAIN_SET.has(blockchain); + } else if (asset !== undefined) { + dfx = ASSET_SET.has(asset); + } + return { gifts, dfx }; +} + +function supportSql( + blockchain: string | undefined, + asset: string | undefined, +): { sql: string; params: string[] } { + if (blockchain !== undefined && asset !== undefined) { + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ? AND asset = ?)', + params: [blockchain, asset], + }; + } + if (blockchain !== undefined) { + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)', + params: [blockchain], + }; + } + if (asset !== undefined) { + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE asset = ?)', + params: [asset], + }; + } + /* v8 ignore next -- @preserve a matching offer always names a blockchain or an asset */ + return { sql: '0', params: [] }; +} + +function assetsFor(blockchain: string | undefined): string[] { + if (blockchain === undefined) { + return [...ASSET_SET]; + } + const values: string[] = []; + for (const pair of DFX_PAIRS) { + if (pair.blockchain === blockchain) { + values.push(pair.asset); + } + } + return values; +} + +/** + * Whether a query network is in the hardcoded payment-link catalog. + * + * @param value - Trimmed blockchain query. + * @returns True for a catalog network, including BinancePay and KucoinPay. + */ +export function isCatalogBlockchain(value: string): boolean { + return CHAIN_SET.has(value); +} + +/** + * Whether a query asset is in the hardcoded payment-link catalog. + * + * @param value - Trimmed asset query. + * @returns True for a catalog asset, including ckBTC, USDC.e, and USDbC. + */ +export function isCatalogAsset(value: string): boolean { + return ASSET_SET.has(value); +} + +/** + * Whether a pin with no support rows matches a payment filter. + * + * @param origin - Stored origin. + * @param techProvider - Stored tech provider. + * @param blockchain - Requested network, if any. + * @param asset - Requested asset, if any. + * @returns True when the hardcoded offer for that pin includes the request. + */ +export function matchesUnstatedPayment( + origin: string, + techProvider: string, + blockchain: string | undefined, + asset: string | undefined, +): boolean { + if (blockchain === undefined && asset === undefined) { + return true; + } + const offer = offerFlags(blockchain, asset); + if (origin === GIFTS_ORIGIN || techProvider === GIFTS_TECH_PROVIDER) { + return offer.gifts; + } + return offer.dfx; +} + +/** + * SQL for a blockchain or asset filter, including pins with no support rows. + * + * @param blockchain - Requested network, if any. + * @param asset - Requested asset, if any. + * @returns A parenthesised predicate and its bound parameters, or null when + * only stored support rows can match. + */ +export function unstatedPaymentSql( + blockchain?: string | undefined, + asset?: string | undefined, +): { sql: string; params: string[] } | null { + const offer = offerFlags(blockchain, asset); + if (!offer.gifts && !offer.dfx) { + return null; + } + const support = supportSql(blockchain, asset); + const giftsFlag = offer.gifts ? '1' : '0'; + let dfxFlag = '1'; + /* v8 ignore next 3 -- @preserve gifts offers are a subset of the DFX catalog */ + if (!offer.dfx) { + dfxFlag = '0'; + } + return { + sql: `(${support.sql} OR (NOT EXISTS (SELECT 1 FROM map_place_support WHERE place_id = map_place.id) AND (((tech_provider = '21.gifts' OR origin = '21gifts') AND ? = '1') OR (NOT (tech_provider = '21.gifts' OR origin = '21gifts') AND ? = '1'))))`, + params: [...support.params, giftsFlag, dfxFlag], + }; +} + +/** + * Public filter payload: stored values plus the hardcoded payment-link offer. + * + * @param stored - Distinct countries, blockchains, and assets already stored. + * @param blockchain - When set, top-level assets are limited to that network. + * @returns Shop-name tokens, countries, networks, assets, and per-provider offers. + */ +export function publicFilterResponse( + stored: { countries: string[]; blockchains: string[]; assets: string[] }, + blockchain?: string | undefined, +): PublicMapFilters { + return { + shopNames: ['SPAR', 'others'], + countries: stored.countries, + blockchains: uniqueSorted([...DFX_BLOCKCHAINS, ...stored.blockchains]), + assets: uniqueSorted([...assetsFor(blockchain), ...stored.assets]), + techProviders: [ + { + name: 'DFX.swiss', + blockchains: [...DFX_BLOCKCHAINS], + assets: [...ASSET_SET].sort(), + pairs: DFX_PAIRS.map((pair) => ({ blockchain: pair.blockchain, asset: pair.asset })), + }, + { + name: GIFTS_TECH_PROVIDER, + blockchains: [GIFTS_BLOCKCHAIN], + assets: [GIFTS_ASSET], + pairs: [{ blockchain: GIFTS_BLOCKCHAIN, asset: GIFTS_ASSET }], + }, + ], + }; +} diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index 6c2053b1a..06e2bf098 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -3,6 +3,8 @@ * Coordinates are rounded to six decimal places. */ +import { isCatalogAsset, isCatalogBlockchain } from '@/lib/map/filter-catalog'; + export type MapPlaceSupport = { blockchain: string; asset: string; @@ -507,6 +509,26 @@ function normalizeAsset(raw: unknown): { ok: true; value: string } | { ok: false return { ok: true, value: asset }; } +function normalizeQueryBlockchain( + raw: string, +): { ok: true; value: string } | { ok: false; error: string } { + const blockchain = raw.trim(); + if (!PAYMENT_BLOCKCHAINS.has(blockchain) && !isCatalogBlockchain(blockchain)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: blockchain }; +} + +function normalizeQueryAsset( + raw: string, +): { ok: true; value: string } | { ok: false; error: string } { + const asset = raw.trim(); + if (!ASSET_TICKER.test(asset) && !ASSET_PREFIXED.test(asset) && !isCatalogAsset(asset)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: asset }; +} + function normalizeSupports( raw: unknown, ): { ok: true; value: MapPlaceSupport[] } | { ok: false; error: string } { @@ -714,14 +736,14 @@ export function normalizeMapPlaceFilter( value.shopName = parsed.value; } if (blockchain !== undefined) { - const parsed = normalizeBlockchain(blockchain); + const parsed = normalizeQueryBlockchain(blockchain); if (!parsed.ok) { return parsed; } value.blockchain = parsed.value; } if (asset !== undefined) { - const parsed = normalizeAsset(asset); + const parsed = normalizeQueryAsset(asset); if (!parsed.ok) { return parsed; } diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 0260e76b3..9fa5fb836 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -1,4 +1,5 @@ import { Database } from 'bun:sqlite'; +import { unstatedPaymentSql } from '@/lib/map/filter-catalog'; import type { MapPlaceInput, MapPlaceSupport, StoredMapPlace } from '@/lib/map/place'; import { MAP_PLACE_SCHEMA_SQL, type MapPlaceListFilter, type MapPlaceStore } from '@/lib/map/store'; @@ -337,20 +338,26 @@ export class SqliteMapPlaceStore implements MapPlaceStore { where.push('shop_name = ?'); params.push('SPAR'); } else if (filter?.shopName === 'others') { - where.push('shop_name IS NOT NULL AND shop_name <> ?'); + where.push('(shop_name IS NULL OR shop_name <> ?)'); params.push('SPAR'); } - if (filter !== undefined && filter.blockchain !== undefined && filter.asset !== undefined) { - where.push( - 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ? AND asset = ?)', - ); - params.push(filter.blockchain, filter.asset); - } else if (filter !== undefined && filter.blockchain !== undefined) { - where.push('id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)'); - params.push(filter.blockchain); - } else if (filter !== undefined && filter.asset !== undefined) { - where.push('id IN (SELECT place_id FROM map_place_support WHERE asset = ?)'); - params.push(filter.asset); + if (filter !== undefined && (filter.blockchain !== undefined || filter.asset !== undefined)) { + const payment = unstatedPaymentSql(filter.blockchain, filter.asset); + if (payment !== null) { + where.push(payment.sql); + params.push(...payment.params); + } else if (filter.blockchain !== undefined && filter.asset !== undefined) { + where.push( + 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ? AND asset = ?)', + ); + params.push(filter.blockchain, filter.asset); + } else if (filter.blockchain !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)'); + params.push(filter.blockchain); + } else if (filter.asset !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE asset = ?)'); + params.push(filter.asset); + } } const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; const rows = this.#db diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index 7b617cbaa..2b2dbac4e 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -1,3 +1,4 @@ +import { matchesUnstatedPayment } from '@/lib/map/filter-catalog'; import type { MapPlaceInput, MapPlaceQueryFilter, @@ -94,15 +95,18 @@ function matchesFilter(row: StoredMapPlace, filter?: MapPlaceListFilter): boolea if (filter.shopName === 'SPAR' && row.shopName !== 'SPAR') { return false; } - if (filter.shopName === 'others' && (row.shopName === null || row.shopName === 'SPAR')) { + if (filter.shopName === 'others' && row.shopName === 'SPAR') { return false; } if (filter.blockchain !== undefined || filter.asset !== undefined) { - const match = row.supports.some( - (item) => - (filter.blockchain === undefined || item.blockchain === filter.blockchain) && - (filter.asset === undefined || item.asset === filter.asset), - ); + const match = + row.supports.length > 0 + ? row.supports.some( + (item) => + (filter.blockchain === undefined || item.blockchain === filter.blockchain) && + (filter.asset === undefined || item.asset === filter.asset), + ) + : matchesUnstatedPayment(row.origin, row.techProvider, filter.blockchain, filter.asset); if (!match) { return false; } diff --git a/src/routes/map.ts b/src/routes/map.ts index 5447e5b19..bbf323250 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -2,6 +2,7 @@ import { timingSafeEqual } from 'node:crypto'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { pushMapPlace, type FetchLike, type MapPushResult } from '@/lib/map/btcmap'; +import { publicFilterResponse } from '@/lib/map/filter-catalog'; import { normalizeMapPlace, normalizeMapPlaceFilter, @@ -105,12 +106,7 @@ export function mapRoutes(deps: MapRouteDeps): Hono { return c.json({ error: parsed.error }, 400); } const values = deps.store.filters(parsed.value.blockchain); - return c.json({ - shopNames: ['SPAR', 'others'], - countries: values.countries, - blockchains: values.blockchains, - assets: values.assets, - }); + return c.json(publicFilterResponse(values, parsed.value.blockchain)); }); app.post('/places', async (c) => { From 51f55ef7d887e701d8d61b44c7a8f999ecc28cb1 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:22:55 +0200 Subject: [PATCH 2/3] 01a0dc6b - Expect only stored rows for a non-catalog pair Polygon plus ZCHF is not a payment-link catalog pair, so a pin without support rows stays out of that filter. --- src/__tests__/map/store.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index 6288783d3..493f320c9 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -212,7 +212,7 @@ describe('MemoryMapPlaceStore', () => { expect(ids({ blockchain: 'BinancePay' }).sort()).toEqual(['dfx-open']); expect(ids({ asset: 'ckBTC' }).sort()).toEqual(['dfx-open']); expect(ids({ blockchain: 'Bitcoin' }).sort()).toEqual(['dfx-open']); - expect(ids({ blockchain: 'Polygon', asset: 'ZCHF' }).sort()).toEqual(['dfx-open', 'stated']); + expect(ids({ blockchain: 'Polygon', asset: 'ZCHF' })).toEqual(['stated']); expect(ids({ shopName: 'SPAR' })).toEqual([]); expect(ids({ shopName: 'others' }).sort()).toEqual([ 'dfx-open', From 406ad7ad83eb38c11af60297d66cdeb83700d2e9 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:42:10 +0200 Subject: [PATCH 3/3] 01a0dc6b - Ignore the unreachable support branch and align filter errors The dead support predicate is ignored through the function close, and the handbook states the same blockchain and asset rules as the query validator. --- docs/handbook/endpoints.md | 4 ++-- src/lib/map/filter-catalog.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index aee970a4a..d50aa07f4 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -10,14 +10,14 @@ ## Endpoint: GET /map/places - **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `country` is the stored DFX country symbol or null. `shopName` is the stored brand or null. `supports` is the stored blockchain and asset pairs, sorted. The caller id and payment methods are not included. Omitted `origin` is every pin. A present `origin` must be the same token as a stored origin. Optional `country`, `shopName` (`SPAR` or `others`), `blockchain`, and `asset` further restrict the store read. All predicates are AND and run before `limit`. `country` is a stored column, not inferred from an address. `others` includes a pin with no brand. A pin that already has support rows matches only those rows. A pin with none matches the hardcoded payment-link catalog: origin `21gifts` or tech provider `21.gifts` matches Lightning and BTC only, and every other pin matches a catalog network, asset, or pair. -- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. 400 `{ error: "Place origin is invalid" }` when `origin` is present but not a valid origin token. 400 `{ error: "Place country is invalid" }` when `country` is present but not an assigned ISO 3166-1 alpha-2 DFX country symbol. 400 `{ error: "Place shop name is invalid" }` when `shopName` is present and not `SPAR` or `others`. 400 `{ error: "Place support is invalid" }` when `blockchain` or `asset` is present but not a payment-link or stored payment-network name, or not a payment-link asset name. +- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. 400 `{ error: "Place origin is invalid" }` when `origin` is present but not a valid origin token. 400 `{ error: "Place country is invalid" }` when `country` is present but not an assigned ISO 3166-1 alpha-2 DFX country symbol. 400 `{ error: "Place shop name is invalid" }` when `shopName` is present and not `SPAR` or `others`. 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a payment-link or stored payment-network name, or when `asset` is present but not a ticker, a prefixed ticker, or a payment-link catalog name. - **Used by:** The OpenCryptoPay map. - **Auth:** none. Browser calls are allowed from any origin. ## Endpoint: GET /map/filters - **Purpose:** Public list of filter values for the map. `shopNames` is always exactly `["SPAR", "others"]`. `countries` are the distinct stored ISO country codes, sorted. `blockchains` and `assets` are the hardcoded payment-link catalog plus distinct stored values, sorted. `?blockchain=` limits the top-level `assets` to that chain and still returns every blockchain. `techProviders` lists the full hardcoded offer for `DFX.swiss` and for `21.gifts` (Lightning and BTC only) and is not narrowed by `?blockchain=`. -- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a DFX payment-network name. +- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a payment-link or stored payment-network name. - **Used by:** The OpenCryptoPay map filter UI. - **Auth:** none. Browser calls are allowed from any origin. diff --git a/src/lib/map/filter-catalog.ts b/src/lib/map/filter-catalog.ts index 85dfa7ba1..a0fe73d2e 100644 --- a/src/lib/map/filter-catalog.ts +++ b/src/lib/map/filter-catalog.ts @@ -175,14 +175,15 @@ function supportSql( params: [blockchain], }; } - if (asset !== undefined) { - return { - sql: 'id IN (SELECT place_id FROM map_place_support WHERE asset = ?)', - params: [asset], - }; + /* v8 ignore start -- @preserve a matching offer always names a blockchain or an asset */ + if (asset === undefined) { + return { sql: '0', params: [] }; } - /* v8 ignore next -- @preserve a matching offer always names a blockchain or an asset */ - return { sql: '0', params: [] }; + /* v8 ignore stop -- @preserve */ + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE asset = ?)', + params: [asset], + }; } function assetsFor(blockchain: string | undefined): string[] {