diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ad662ae..748273f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,9 @@ name: Allowlist Ci on: + pull_request: + branches: + - main push: branches: - main diff --git a/README.md b/README.md index cde3948..5346683 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,11 @@ lookups. It preserves the v2 NFT endpoint used for historical ownership snapshots. The library does not depend on the archived Alchemy SDK. Applications that inject their own SDK instance continue to own that instance's dependencies. +Historical owner token IDs accept decimal strings and explicitly `0x`-prefixed +hexadecimal strings. Both normalize to exact decimal uint256 IDs without using +JavaScript numbers. Malformed or out-of-range IDs fail instead of being silently +reinterpreted or truncated. + ## Development and release ```sh diff --git a/package-lock.json b/package-lock.json index 81a62b9..c7dde10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2735,36 +2735,36 @@ } }, "node_modules/csv": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/csv/-/csv-6.3.5.tgz", - "integrity": "sha512-Y+KTCAUljtq2JaGP42ZL1bymqlU5BkfnFpZhxRczGFDZox2VXhlRHnG5DRshyUrwQzmCdEiLjSqNldCfm1OVCA==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/csv/-/csv-6.6.3.tgz", + "integrity": "sha512-X2AnOgcxqV+OdLm1M2FOl+bPQrM1LK1jwo4FhEoq8hi8JTR0tQ8ZWv3x3N2gVMIuUmpb4CLrV08rTP49IdFQ8w==", "license": "MIT", "dependencies": { - "csv-generate": "^4.3.0", - "csv-parse": "^5.5.2", - "csv-stringify": "^6.4.4", - "stream-transform": "^3.2.10" + "csv-generate": "^4.6.1", + "csv-parse": "^7.0.2", + "csv-stringify": "^6.8.3", + "stream-transform": "^3.5.1" }, "engines": { "node": ">= 0.1.90" } }, "node_modules/csv-generate": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.3.0.tgz", - "integrity": "sha512-7KdVId/2RgwPIKfWHaHtjBq7I9mgdi8ICzsUyIhP8is6UwpwVGGSC/aPnrZ8/SkgBcCP20lXrdPuP64Irs1VBg==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.6.1.tgz", + "integrity": "sha512-eELl9K716LSSeP2/YcCjch525JztnnERe3jEARWw2v1FN9ukUYfZTNYZ4Rq2Jj/MFKMauffOy9VCaqQTpFThDQ==", "license": "MIT" }, "node_modules/csv-parse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.2.tgz", - "integrity": "sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz", + "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==", "license": "MIT" }, "node_modules/csv-stringify": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.4.4.tgz", - "integrity": "sha512-NDshLupGa7gp4UG4sSNIqwYJqgSwvds0SvENntxoVoVvTzXcrHvd5gG2MWpbRpSNvk59dlmIe1IwNvSxN4IVmg==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz", + "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==", "license": "MIT" }, "node_modules/debug": { @@ -6763,9 +6763,9 @@ } }, "node_modules/stream-transform": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.10.tgz", - "integrity": "sha512-Yu+x7zcWbWdyB0Td8dFzHt2JEyD6694CNq2lqh1rbuEBVxPtjb/GZ7xDnZcdYiU5E/RtufM54ClSEOzZDeWguA==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.5.1.tgz", + "integrity": "sha512-TTDX+qKFr7GGRXATn66rprmlFPx08W0UBIccE/rMPgW2sT7GovduZYP4xcdJ7Nu2YigF17U+CNFxYY11+W1oPw==", "license": "MIT" }, "node_modules/string_decoder": { diff --git a/src/errors/step-error.ts b/src/errors/step-error.ts index 4aa67fa..f05626a 100644 --- a/src/errors/step-error.ts +++ b/src/errors/step-error.ts @@ -49,7 +49,9 @@ export function formatStepErrorMetadata( return ''; } - const entries = Object.entries(metadata).filter(([, value]) => value !== undefined); + const entries = Object.entries(metadata).filter( + ([, value]) => value !== undefined, + ); if (!entries.length) { return ''; } diff --git a/src/services/alchemy-client.spec.ts b/src/services/alchemy-client.spec.ts index 61edd32..b60d19c 100644 --- a/src/services/alchemy-client.spec.ts +++ b/src/services/alchemy-client.spec.ts @@ -40,7 +40,7 @@ describe('Alchemy HTTP client', () => { ownerAddresses: [ { ownerAddress: '0xowner1', - tokenBalances: [{ tokenId: '0x0a', balance: '2' }], + tokenBalances: [{ tokenId: '10', balance: '2' }], }, ], pageKey: 'next-page', diff --git a/src/services/alchemy.service.spec.ts b/src/services/alchemy.service.spec.ts new file mode 100644 index 0000000..21ad1ed --- /dev/null +++ b/src/services/alchemy.service.spec.ts @@ -0,0 +1,69 @@ +import { AlchemyClient } from './alchemy-client'; +import { AlchemyService } from './alchemy.service'; + +const UINT256_MAX = + '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + +describe('Alchemy ownership token IDs', () => { + function serviceFor(tokenId: unknown) { + const client: AlchemyClient = { + nft: { + getOwnersForContract: jest.fn().mockResolvedValue({ + owners: [ + { + ownerAddress: '0xowner', + tokenBalances: [{ tokenId, balance: 2 }], + }, + ], + }), + }, + core: { resolveName: jest.fn(), lookupAddress: jest.fn() }, + }; + return new AlchemyService(client); + } + + 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], + ])('reads %s as token %s without precision loss', async (input, expected) => { + await expect( + serviceFor(input).getCollectionOwnersInBlock({ + contract: '0xcontract', + block: 123, + }), + ).resolves.toEqual([ + { ownerAddress: '0xowner', 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 token ID %s', async (input) => { + await expect( + serviceFor(input).getCollectionOwnersInBlock({ contract: '0xcontract' }), + ).rejects.toThrow('Invalid Alchemy token ID'); + }); +}); diff --git a/src/services/alchemy.service.ts b/src/services/alchemy.service.ts index 1a664d0..3e632a1 100644 --- a/src/services/alchemy.service.ts +++ b/src/services/alchemy.service.ts @@ -1,9 +1,26 @@ import { AlchemyClient, AlchemyOwnersOptions } from './alchemy-client'; import { CollectionOwner } from './collection-owner'; +const UINT256_LIMIT = BigInt(2) ** BigInt(256); + export class AlchemyService { constructor(private readonly alchemy: AlchemyClient) {} + private normalizeTokenId(tokenId: unknown): string { + if ( + typeof tokenId !== 'string' || + !/^(?:\d+|0x[0-9a-f]+)$/i.test(tokenId) + ) { + throw new Error('Invalid Alchemy token ID'); + } + // REST responses may use decimal IDs; only an explicit 0x prefix means hex. + const value = BigInt(tokenId); + if (value >= UINT256_LIMIT) { + throw new Error('Invalid Alchemy token ID'); + } + return value.toString(); + } + async getCollectionOwnersInBlock({ contract, block, @@ -31,9 +48,7 @@ export class AlchemyService { (owner) => ({ ownerAddress: owner.ownerAddress, tokens: owner.tokenBalances.map((token) => ({ - tokenId: BigInt( - `0x${token.tokenId.replace('0x', '').substring(0, 64)}`, - ).toString(), + tokenId: this.normalizeTokenId(token.tokenId), balance: +token.balance, })), }), diff --git a/src/services/seize/seize.api.spec.ts b/src/services/seize/seize.api.spec.ts index ae0c089..8860820 100644 --- a/src/services/seize/seize.api.spec.ts +++ b/src/services/seize/seize.api.spec.ts @@ -216,8 +216,12 @@ describe('Seize API Uploads', () => { }); it('should retry upload download across gateway priority list with normal urls', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const logSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); try { const tdhInfos = await seizeApi.getUploadsForBlock(17531454); @@ -264,7 +268,9 @@ describe('Seize API Uploads', () => { }); it('should normalize raw upload urls back to normal gateway urls', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const logSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); try { const tdhInfos = await seizeApi.getUploadsForBlock(17531455); diff --git a/src/services/seize/tdh-info.ts b/src/services/seize/tdh-info.ts index 770bdfe..69e9eab 100644 --- a/src/services/seize/tdh-info.ts +++ b/src/services/seize/tdh-info.ts @@ -1,4 +1,3 @@ - export interface CommonTdhInfo { readonly consolidation_key: string; readonly consolidation_display: string; diff --git a/yarn.lock b/yarn.lock index 82ab2e6..e5671fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1569,30 +1569,30 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -csv-generate@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/csv-generate/-/csv-generate-4.3.0.tgz" - integrity sha512-7KdVId/2RgwPIKfWHaHtjBq7I9mgdi8ICzsUyIhP8is6UwpwVGGSC/aPnrZ8/SkgBcCP20lXrdPuP64Irs1VBg== +csv-generate@^4.6.1: + version "4.6.1" + resolved "https://registry.npmjs.org/csv-generate/-/csv-generate-4.6.1.tgz" + integrity sha512-eELl9K716LSSeP2/YcCjch525JztnnERe3jEARWw2v1FN9ukUYfZTNYZ4Rq2Jj/MFKMauffOy9VCaqQTpFThDQ== -csv-parse@^5.5.2: - version "5.5.2" - resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.2.tgz" - integrity sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA== +csv-parse@^7.0.2: + version "7.0.2" + resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz" + integrity sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA== -csv-stringify@^6.4.4: - version "6.4.4" - resolved "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.4.4.tgz" - integrity sha512-NDshLupGa7gp4UG4sSNIqwYJqgSwvds0SvENntxoVoVvTzXcrHvd5gG2MWpbRpSNvk59dlmIe1IwNvSxN4IVmg== +csv-stringify@^6.8.3: + version "6.8.3" + resolved "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz" + integrity sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA== csv@^6.3.1: - version "6.3.5" - resolved "https://registry.npmjs.org/csv/-/csv-6.3.5.tgz" - integrity sha512-Y+KTCAUljtq2JaGP42ZL1bymqlU5BkfnFpZhxRczGFDZox2VXhlRHnG5DRshyUrwQzmCdEiLjSqNldCfm1OVCA== + version "6.6.3" + resolved "https://registry.npmjs.org/csv/-/csv-6.6.3.tgz" + integrity sha512-X2AnOgcxqV+OdLm1M2FOl+bPQrM1LK1jwo4FhEoq8hi8JTR0tQ8ZWv3x3N2gVMIuUmpb4CLrV08rTP49IdFQ8w== dependencies: - csv-generate "^4.3.0" - csv-parse "^5.5.2" - csv-stringify "^6.4.4" - stream-transform "^3.2.10" + csv-generate "^4.6.1" + csv-parse "^7.0.2" + csv-stringify "^6.8.3" + stream-transform "^3.5.1" debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@4: version "4.3.4" @@ -3713,10 +3713,10 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" -stream-transform@^3.2.10: - version "3.2.10" - resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.10.tgz" - integrity sha512-Yu+x7zcWbWdyB0Td8dFzHt2JEyD6694CNq2lqh1rbuEBVxPtjb/GZ7xDnZcdYiU5E/RtufM54ClSEOzZDeWguA== +stream-transform@^3.5.1: + version "3.5.1" + resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-3.5.1.tgz" + integrity sha512-TTDX+qKFr7GGRXATn66rprmlFPx08W0UBIccE/rMPgW2sT7GovduZYP4xcdJ7Nu2YigF17U+CNFxYY11+W1oPw== string_decoder@^1.1.1: version "1.3.0"