From 58db474327bdc4479fd21b3e3e750fb1667fc41b Mon Sep 17 00:00:00 2001 From: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:48:52 +0300 Subject: [PATCH 1/3] Fix decimal owner token IDs in EMMA snapshots Signed-off-by: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> --- .github/workflows/build.yml | 2 + .github/workflows/staging.yml | 31 ++- README.md | 14 ++ scripts/deploy-lambdas.cjs | 3 +- scripts/lambda-services.cjs | 10 + scripts/lambda-services.test.cjs | 25 +++ scripts/smoke-lambdas.cjs | 3 +- src/alchemy-api/alchemy-api.client.ts | 27 ++- .../alchemy-ownership-compatibility.spec.ts | 192 ++++++++++++++++++ 9 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 scripts/lambda-services.cjs create mode 100644 scripts/lambda-services.test.cjs create mode 100644 src/alchemy-api/alchemy-ownership-compatibility.spec.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f76dcd..e4e31f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,8 @@ jobs: run: yarn build - name: Run unit tests run: yarn test + - name: Test deployment selection + run: node --test scripts/lambda-services.test.cjs - name: Inject Sentry source-map metadata run: yarn sentry:sourcemaps:inject - name: Package Lambdas diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 49391a8..385015d 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -1,6 +1,21 @@ name: Allowlist API Staging CI -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + service: + description: Deploy one service; run downloader, worker, then API in order + required: true + default: allowlist-token-pool-downloader + type: choice + options: + - allowlist-token-pool-downloader + - allowlist-worker + - allowlist-api + +concurrency: + group: allowlist-staging-deploy + cancel-in-progress: false env: AWS_REGION: eu-west-1 @@ -33,6 +48,8 @@ jobs: run: yarn build - name: Run unit tests run: yarn test + - name: Test deployment selection + run: node --test scripts/lambda-services.test.cjs - name: Inject Sentry source-map metadata run: yarn sentry:sourcemaps:inject - name: Package Lambdas @@ -47,10 +64,14 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: eu-west-1 - - name: Deploy Lambdas sequentially - run: yarn lambda:deploy:staging - - name: Cold-start smoke test Lambdas - run: yarn lambda:smoke:staging + - name: Deploy selected Lambda + env: + LAMBDA_SERVICE: ${{ inputs.service }} + run: yarn lambda:deploy:staging "$LAMBDA_SERVICE" + - name: Cold-start smoke test selected Lambda + env: + LAMBDA_SERVICE: ${{ inputs.service }} + run: yarn lambda:smoke:staging "$LAMBDA_SERVICE" - name: Notify about failure uses: sarisia/actions-status-discord@v1 if: failure() diff --git a/README.md b/README.md index 99b88bb..28b2e9f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,13 @@ before the API can enqueue new work. Compatibility handlers under `src/` are included in the artifact, so changing the configured handlers to `dist/` cannot create a code/config transition outage. +For staging, dispatch `staging.yml` on `1a-staging` once per Lambda using its +`service` input. Wait for each deployment and smoke test to pass before +dispatching the next service: `allowlist-token-pool-downloader`, +`allowlist-worker`, then `allowlist-api`. The deploy and smoke commands also +accept this optional service name; omitting it retains the all-service behavior. +An invalid service name fails without deploying anything. + After staging and production deploys, `yarn lambda:smoke:staging` and `yarn lambda:smoke:prod` directly invoke all three functions in their respective regions. The worker events use a reserved, side-effect-free bootstrap probe; the @@ -130,6 +137,13 @@ fallback metadata for 6529 collections. Both retain their existing response schema. Exact-address lookup uses Alchemy V3 `getContractMetadata`; historical owner snapshots still use V2 `getOwnersForCollection`. +Owner token IDs may be decimal strings or explicitly `0x`-prefixed hexadecimal +strings. The API adapter validates them as uint256 values and passes canonical +hexadecimal IDs to the library. This preserves exact IDs with published +`allowlist-lib` 0.0.136 as well as the corrected decimal-aware library parser; +the API fix can therefore deploy before a new library release. Existing stored +snapshots are unchanged and affected empty snapshots must be recreated. + The retired `POST /other/search-contract-metadata` route is no longer available. Deploy the compatible frontend before this API cleanup, verify the collection and manual-entry flows, then confirm the old route is unused through request diff --git a/scripts/deploy-lambdas.cjs b/scripts/deploy-lambdas.cjs index 4d1bae4..457bef3 100644 --- a/scripts/deploy-lambdas.cjs +++ b/scripts/deploy-lambdas.cjs @@ -7,6 +7,7 @@ const { } = require('node:fs'); const { tmpdir } = require('node:os'); const { join, resolve } = require('node:path'); +const { selectFunctions } = require('./lambda-services.cjs'); const environment = process.argv[2]; const regions = { @@ -51,7 +52,7 @@ function aws(args, capture = false) { ); } -for (const lambda of functions) { +for (const lambda of selectFunctions(functions, process.argv[3])) { console.log(`Deploying ${lambda.name} to ${environment} (${region})`); const revisionId = aws( [ diff --git a/scripts/lambda-services.cjs b/scripts/lambda-services.cjs new file mode 100644 index 0000000..f2a9ac6 --- /dev/null +++ b/scripts/lambda-services.cjs @@ -0,0 +1,10 @@ +function selectFunctions(functions, service) { + if (service === undefined) return functions; + const selected = functions.filter(({ name }) => name === service); + if (selected.length !== 1) { + throw new Error(`Unknown Lambda service: ${service}`); + } + return selected; +} + +module.exports = { selectFunctions }; diff --git a/scripts/lambda-services.test.cjs b/scripts/lambda-services.test.cjs new file mode 100644 index 0000000..56b409b --- /dev/null +++ b/scripts/lambda-services.test.cjs @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { selectFunctions } = require('./lambda-services.cjs'); + +const functions = [ + { name: 'allowlist-token-pool-downloader' }, + { name: 'allowlist-worker' }, + { name: 'allowlist-api' }, +]; + +test('default deployment retains worker-before-API order', () => { + assert.deepEqual(selectFunctions(functions), functions); +}); + +for (const lambda of functions) { + test(`selects only ${lambda.name}`, () => { + assert.deepEqual(selectFunctions(functions, lambda.name), [lambda]); + }); +} + +test('an invalid selector never falls back to deploying every function', () => { + for (const service of ['', 'all', 'allowlist-typo']) { + assert.throws(() => selectFunctions(functions, service), /Unknown Lambda service/); + } +}); diff --git a/scripts/smoke-lambdas.cjs b/scripts/smoke-lambdas.cjs index 87cf22c..e2527ee 100644 --- a/scripts/smoke-lambdas.cjs +++ b/scripts/smoke-lambdas.cjs @@ -2,6 +2,7 @@ const { execFileSync, spawnSync } = require('node:child_process'); const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); const { tmpdir } = require('node:os'); const { join } = require('node:path'); +const { selectFunctions } = require('./lambda-services.cjs'); const environment = process.argv[2]; const regions = { @@ -133,7 +134,7 @@ function invoke(lambda, responseFile) { } try { - for (const lambda of functions) { + for (const lambda of selectFunctions(functions, process.argv[3])) { const responseFile = join(scratchDirectory, `${lambda.name}.json`); writeFileSync(responseFile, '', { mode: 0o600 }); const metadata = invoke(lambda, responseFile); diff --git a/src/alchemy-api/alchemy-api.client.ts b/src/alchemy-api/alchemy-api.client.ts index 18dc6f2..3ffae69 100644 --- a/src/alchemy-api/alchemy-api.client.ts +++ b/src/alchemy-api/alchemy-api.client.ts @@ -182,11 +182,34 @@ export class AlchemyApiClient implements AllowlistAlchemyClient { throw new Error('Invalid Alchemy owners response'); } return { - owners: response.ownerAddresses, + owners: response.ownerAddresses.map((owner) => ({ + ...owner, + tokenBalances: owner.tokenBalances.map((token) => ({ + ...token, + tokenId: this.normalizeOwnerTokenId(token.tokenId), + })), + })), ...(response.pageKey !== undefined && { pageKey: response.pageKey }), }; } + private normalizeOwnerTokenId(value: unknown): string { + if (!this.isTokenId(value) || BigInt(value) >= 2n ** 256n) { + throw new UpstreamProviderError( + 'Alchemy', + 'invalid-response', + 200, + undefined, + 'Invalid owners token ID', + 'Invalid Alchemy owners token ID', + ); + } + // The published allowlist-lib still interprets every ID as hexadecimal. + // Explicit hex also works with the corrected library, allowing either order + // of deployment without confusing decimal token 10 with token 16. + return `0x${BigInt(value).toString(16)}`; + } + private normalizeTokenType(tokenType?: string): string { switch (tokenType) { case 'erc721': @@ -207,7 +230,7 @@ export class AlchemyApiClient implements AllowlistAlchemyClient { } private isTokenId(value: unknown): value is string { - return typeof value === 'string' && /^(?:\d+|0x[0-9a-fA-F]+)$/.test(value); + return typeof value === 'string' && /^(?:\d+|0x[0-9a-f]+)$/i.test(value); } private normalizeOpenSeaMetadata( diff --git a/src/alchemy-api/alchemy-ownership-compatibility.spec.ts b/src/alchemy-api/alchemy-ownership-compatibility.spec.ts new file mode 100644 index 0000000..ece9d02 --- /dev/null +++ b/src/alchemy-api/alchemy-ownership-compatibility.spec.ts @@ -0,0 +1,192 @@ +import { HttpService } from '@nestjs/axios'; +import { jest } from '@jest/globals'; +import { + AllowlistCreator, + AllowlistOperationCode, +} from '@6529-collections/allowlist-lib'; +import { ContractSchema } from '@6529-collections/allowlist-lib/app-types'; +import { AlchemyService } from '@6529-collections/allowlist-lib/services/alchemy.service'; +import { AlchemyApiClient, AlchemyJsonRpcProvider } from './alchemy-api.client'; +import { AlchemyConfig } from './alchemy.config'; + +const CONTRACT = '0x33fd426905f149f8376e227d0c9d3340aad17af1'; +const OWNER_10 = '0x1111111111111111111111111111111111111111'; +const OWNER_16 = '0x2222222222222222222222222222222222222222'; +const UINT256_MAX = + '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + +describe('Alchemy ownership compatibility with the published library', () => { + const axiosGet = jest.fn(); + let client: AlchemyApiClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new AlchemyApiClient( + new AlchemyConfig({ key: 'test-key' }), + { axiosRef: { get: axiosGet } } as unknown as HttpService, + {} as AlchemyJsonRpcProvider, + ); + }); + + it.each([ + ['0', '0'], + ['10', '10'], + ['16', '16'], + ['00010', '10'], + ['0x0a', '10'], + ['0X0A', '10'], + ['0x0010', '16'], + ['9007199254740993', '9007199254740993'], + [UINT256_MAX, UINT256_MAX], + [`0x${'f'.repeat(64)}`, UINT256_MAX], + ])( + 'preserves provider ID %s as decimal token %s', + async (input, expected) => { + axiosGet.mockResolvedValue({ + data: { + ownerAddresses: [ + { + ownerAddress: OWNER_10, + tokenBalances: [{ tokenId: input, balance: '2' }], + }, + ], + }, + }); + await expect( + new AlchemyService(client).getCollectionOwnersInBlock({ + contract: CONTRACT, + block: 26031727, + }), + ).resolves.toEqual([ + { ownerAddress: OWNER_10, tokens: [{ tokenId: expected, balance: 2 }] }, + ]); + }, + ); + + it.each([ + '', + ' ', + ' 10', + '10 ', + '-1', + '+10', + '1.5', + '1e2', + '0x', + '0xgg', + 'a', + `${UINT256_MAX}0`, + `0x1${'0'.repeat(64)}`, + null, + undefined, + 10, + ])('rejects malformed or out-of-range owner token ID %s', async (tokenId) => { + axiosGet.mockResolvedValue({ + data: { + ownerAddresses: [ + { + ownerAddress: OWNER_10, + tokenBalances: [{ tokenId, balance: 1 }], + }, + ], + }, + }); + await expect( + client.nft.getOwnersForContract(CONTRACT, { + withTokenBalances: true, + block: '26031727', + }), + ).rejects.toThrow('Invalid Alchemy owners token ID'); + }); + + it.each([ + ['10', OWNER_10, 2], + ['16', OWNER_16, 1], + ])( + 'selects only token %s holders across provider pages', + async (tokenId, owner, count) => { + axiosGet + .mockResolvedValueOnce({ + data: { + ownerAddresses: [ + { + ownerAddress: OWNER_10, + tokenBalances: [{ tokenId: '10', balance: 2 }], + }, + ], + pageKey: 'page-two', + }, + }) + .mockResolvedValueOnce({ + data: { + ownerAddresses: [ + { + ownerAddress: OWNER_16, + tokenBalances: [{ tokenId: '16', balance: 1 }], + }, + ], + }, + }); + const creator = AllowlistCreator.getInstance({ + alchemy: client, + etherscanApiKey: 'test-key', + seizeApiPath: '', + ofacCheckEnabled: false, + storage: { + tokenPoolStorage: { getTokenPoolTokens: async () => [] }, + transfersStorage: { + getLatestTransferBlockNo: async () => 0, + getContractTransfersOrdered: async () => [], + saveContractTransfers: async () => {}, + }, + }, + }); + jest + .spyOn(creator.etherscanService, 'getContractSchema') + .mockResolvedValue(ContractSchema.ERC1155); + + const state = await creator.execute([ + { + code: AllowlistOperationCode.CREATE_ALLOWLIST, + params: { + id: 'plan', + name: 'Plan', + description: 'Parser regression plan', + }, + }, + { + code: AllowlistOperationCode.CREATE_TOKEN_POOL, + params: { + id: 'pool', + name: 'Pool', + description: 'Parser regression pool', + contract: CONTRACT, + blockNo: 26031727, + tokenIds: tokenId, + consolidateBlockNo: null, + }, + }, + ]); + expect(state.tokenPools.pool.tokens).toEqual( + Array.from({ length: count }, () => ({ + id: tokenId, + owner, + contract: CONTRACT, + })), + ); + expect(axiosGet).toHaveBeenCalledTimes(2); + expect(axiosGet).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ + params: { + contractAddress: CONTRACT, + withTokenBalances: true, + block: '26031727', + pageKey: 'page-two', + }, + }), + ); + }, + ); +}); From 77de72c083fb3717b611a3522344d2ed6d55eaed Mon Sep 17 00:00:00 2001 From: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:53:51 +0300 Subject: [PATCH 2/3] Validate malformed owner balance responses Signed-off-by: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> --- src/alchemy-api/alchemy-api.client.ts | 29 ++++++++++++++----- .../alchemy-ownership-compatibility.spec.ts | 13 +++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/alchemy-api/alchemy-api.client.ts b/src/alchemy-api/alchemy-api.client.ts index 3ffae69..53c3466 100644 --- a/src/alchemy-api/alchemy-api.client.ts +++ b/src/alchemy-api/alchemy-api.client.ts @@ -24,6 +24,7 @@ const OPENSEA_SAFELIST_STATUSES = new Set([ const MAX_HTTP_ATTEMPTS = 5; const INITIAL_RETRY_DELAY_MS = 1_000; const RETRY_MULTIPLIER = 1.5; +const UINT256_LIMIT = 2n ** 256n; export interface AlchemyJsonRpcProvider { getBlockNumber(): Promise; @@ -182,19 +183,31 @@ export class AlchemyApiClient implements AllowlistAlchemyClient { throw new Error('Invalid Alchemy owners response'); } return { - owners: response.ownerAddresses.map((owner) => ({ - ...owner, - tokenBalances: owner.tokenBalances.map((token) => ({ - ...token, - tokenId: this.normalizeOwnerTokenId(token.tokenId), - })), - })), + owners: response.ownerAddresses.map((owner) => { + if (!owner || !Array.isArray(owner.tokenBalances)) { + throw new UpstreamProviderError( + 'Alchemy', + 'invalid-response', + 200, + undefined, + 'Invalid owners token balances', + 'Invalid Alchemy owners token balances', + ); + } + return { + ...owner, + tokenBalances: owner.tokenBalances.map((token) => ({ + ...token, + tokenId: this.normalizeOwnerTokenId(token?.tokenId), + })), + }; + }), ...(response.pageKey !== undefined && { pageKey: response.pageKey }), }; } private normalizeOwnerTokenId(value: unknown): string { - if (!this.isTokenId(value) || BigInt(value) >= 2n ** 256n) { + if (!this.isTokenId(value) || BigInt(value) >= UINT256_LIMIT) { throw new UpstreamProviderError( 'Alchemy', 'invalid-response', diff --git a/src/alchemy-api/alchemy-ownership-compatibility.spec.ts b/src/alchemy-api/alchemy-ownership-compatibility.spec.ts index ece9d02..4147dab 100644 --- a/src/alchemy-api/alchemy-ownership-compatibility.spec.ts +++ b/src/alchemy-api/alchemy-ownership-compatibility.spec.ts @@ -99,6 +99,19 @@ describe('Alchemy ownership compatibility with the published library', () => { ).rejects.toThrow('Invalid Alchemy owners token ID'); }); + it.each([null, {}, { tokenBalances: null }, { tokenBalances: {} }])( + 'rejects malformed owner entry %j with a controlled provider error', + async (owner) => { + axiosGet.mockResolvedValue({ data: { ownerAddresses: [owner] } }); + await expect( + client.nft.getOwnersForContract(CONTRACT, { + withTokenBalances: true, + block: '26031727', + }), + ).rejects.toThrow('Invalid Alchemy owners token balances'); + }, + ); + it.each([ ['10', OWNER_10, 2], ['16', OWNER_16, 1], From 766a0dace798233c65ebca60efdf97a1d736427b Mon Sep 17 00:00:00 2001 From: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:37:53 +0300 Subject: [PATCH 3/3] Deploy one production Lambda per workflow invocation Signed-off-by: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> --- .github/workflows/production.yml | 32 +++++++++++++++++++++++++++----- README.md | 6 ++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 62d248b..1a72bf0 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -1,6 +1,21 @@ name: Allowlist API Production CI -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + service: + description: Deploy one service; run downloader, worker, then API in order + required: true + default: allowlist-token-pool-downloader + type: choice + options: + - allowlist-token-pool-downloader + - allowlist-worker + - allowlist-api + +concurrency: + group: allowlist-production-deploy + cancel-in-progress: false env: AWS_REGION: us-east-1 @@ -11,6 +26,7 @@ env: jobs: build-test-and-deploy: name: Build, test and deploy + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: @@ -33,6 +49,8 @@ jobs: run: yarn build - name: Run unit tests run: yarn test + - name: Test deployment selection + run: node --test scripts/lambda-services.test.cjs - name: Inject Sentry source-map metadata run: yarn sentry:sourcemaps:inject - name: Package Lambdas @@ -47,10 +65,14 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - name: Deploy Lambdas sequentially - run: yarn lambda:deploy:prod - - name: Cold-start smoke test Lambdas - run: yarn lambda:smoke:prod + - name: Deploy selected Lambda + env: + LAMBDA_SERVICE: ${{ inputs.service }} + run: yarn lambda:deploy:prod "$LAMBDA_SERVICE" + - name: Cold-start smoke test selected Lambda + env: + LAMBDA_SERVICE: ${{ inputs.service }} + run: yarn lambda:smoke:prod "$LAMBDA_SERVICE" - name: Notify about failure uses: sarisia/actions-status-discord@v1 if: failure() diff --git a/README.md b/README.md index 28b2e9f..f70f30f 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ before the API can enqueue new work. Compatibility handlers under `src/` are included in the artifact, so changing the configured handlers to `dist/` cannot create a code/config transition outage. -For staging, dispatch `staging.yml` on `1a-staging` once per Lambda using its -`service` input. Wait for each deployment and smoke test to pass before +For staging, dispatch `staging.yml` on `1a-staging`; for production, dispatch +`production.yml` on `main`. Each dispatch deploys one Lambda using its `service` +input. The production workflow rejects non-main refs. Wait for each deployment +and smoke test to pass before dispatching the next service: `allowlist-token-pool-downloader`, `allowlist-worker`, then `allowlist-api`. The deploy and smoke commands also accept this optional service name; omitting it retains the all-service behavior.