Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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()
Expand Down
31 changes: 26 additions & 5 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ 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`; 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.
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
Expand Down Expand Up @@ -130,6 +139,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
Expand Down
3 changes: 2 additions & 1 deletion scripts/deploy-lambdas.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
[
Expand Down
10 changes: 10 additions & 0 deletions scripts/lambda-services.cjs
Original file line number Diff line number Diff line change
@@ -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 };
25 changes: 25 additions & 0 deletions scripts/lambda-services.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
}
});
3 changes: 2 additions & 1 deletion scripts/smoke-lambdas.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 38 additions & 2 deletions src/alchemy-api/alchemy-api.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
Expand Down Expand Up @@ -182,11 +183,46 @@ export class AlchemyApiClient implements AllowlistAlchemyClient {
throw new Error('Invalid Alchemy owners response');
}
return {
owners: response.ownerAddresses,
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) >= UINT256_LIMIT) {
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':
Expand All @@ -207,7 +243,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(
Expand Down
Loading
Loading