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..d50aa07f4 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,15 +9,15 @@ ## 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` 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` are the distinct support blockchains, sorted. `assets` are the distinct support assets, sorted. `?blockchain=` limits `assets` to that chain and still returns every blockchain. -- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a DFX payment-network name. +- **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 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/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..493f320c9 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' })).toEqual(['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..a0fe73d2e --- /dev/null +++ b/src/lib/map/filter-catalog.ts @@ -0,0 +1,307 @@ +/** + * 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], + }; + } + /* v8 ignore start -- @preserve a matching offer always names a blockchain or an asset */ + if (asset === undefined) { + 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[] { + 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) => {