Add `verify_token_metadata` command (#441)

* Add `verify_token_metadata` command

* Removing async wrapper as it was causing some strange traces

* Removing comment

* Checking against `image.png`

* Sorting

* Removing absolute path from snapshot

* Set node version

* only running tests in the CLI
This commit is contained in:
jon wong 2021-09-26 18:20:57 -04:00 committed by GitHub
parent 2e4817fe81
commit 048ddd6bac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1161 additions and 23 deletions

34
.github/workflows/cli-pull-request.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Pull Request (CLI)
on:
pull_request:
paths:
- js/packages/cli/*
push:
branches:
- master
paths:
- js/packages/cli/*
jobs:
unit_tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v2
with:
node-version: "14"
- uses: actions/cache@v2
with:
path: "**/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install modules
run: yarn install
working-directory: js/packages/cli
- name: Run Tests
run: yarn test
working-directory: js/packages/cli

1
js/packages/cli/.nvmrc Normal file
View File

@ -0,0 +1 @@
14.17.0

View File

@ -16,10 +16,30 @@
"format": "prettier --loglevel warn --write \"**/*.{ts,js,json,yaml}\"",
"format:check": "prettier --loglevel warn --check \"**/*.{ts,js,json,yaml}\"",
"lint": "eslint \"src/**/*.ts\" --fix",
"lint:check": "eslint \"src/**/*.ts\""
"lint:check": "eslint \"src/**/*.ts\"",
"test": "jest"
},
"pkg": {
"scripts": "./build/**/*.js"
"scripts": "./build/**/*.{js|json}"
},
"babel": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
],
"@babel/preset-typescript"
]
},
"jest": {
"testPathIgnorePatterns": [
"<rootDir>/build/",
"<rootDir>/node_modules/"
]
},
"dependencies": {
"@project-serum/anchor": "^0.14.0",
@ -30,10 +50,15 @@
"commander": "^8.1.0",
"form-data": "^4.0.0",
"ipfs-http-client": "^52.0.3",
"jsonschema": "^1.4.0",
"loglevel": "^1.7.1",
"node-fetch": "^2.6.1"
},
"devDependencies": {
"@babel/preset-env": "^7.15.6",
"@babel/preset-typescript": "^7.15.0",
"@types/jest": "^27.0.1",
"jest": "^27.2.0",
"pkg": "^5.3.1",
"typescript": "^4.3.5"
}

View File

@ -23,6 +23,7 @@ import {
} from './helpers/accounts';
import { Config } from './types';
import { upload } from './commands/upload';
import { verifyTokenMetadata } from './commands/verifyTokenMetadata';
import { loadCache, saveCache } from './helpers/cache';
import { mint } from './commands/mint';
import { signMetadata } from './commands/sign';
@ -142,6 +143,31 @@ programCommand('upload')
}
});
programCommand('verify_token_metadata')
.argument(
'<directory>',
'Directory containing images and metadata files named from 0-n',
val => {
return fs
.readdirSync(`${val}`)
.map(file => path.join(process.cwd(), val, file));
},
)
.option('-n, --number <number>', 'Number of images to upload')
.action((files: string[], options, cmd) => {
const { number } = cmd.opts();
const startMs = Date.now();
log.info('started at: ' + startMs.toString());
verifyTokenMetadata({ files, uploadElementsCount: number });
const endMs = Date.now();
const timeTaken = new Date(endMs - startMs).toISOString().substr(11, 8);
log.info(
`ended at: ${new Date(endMs).toString()}. time taken: ${timeTaken}`,
);
});
programCommand('verify').action(async (directory, cmd) => {
const { env, keypair, cacheName } = cmd.opts();

View File

@ -0,0 +1,16 @@
{
"name": "Invalid shares",
"description": "",
"image": "0.png",
"external_url": "",
"seller_fee_basis_points": 0,
"properties": {
"files": [{ "uri": "0.png", "type": "image/png" }],
"creators": [
{
"address": "111111111111111111111111111111",
"share": 100
}
]
}
}

View File

@ -0,0 +1,16 @@
{
"name": "Invalid shares",
"description": "",
"image": "0.png",
"external_url": "",
"seller_fee_basis_points": 0,
"properties": {
"files": [{ "uri": "0.png", "type": "image/png" }],
"creators": [
{
"address": "111111111111111111111111111111111",
"share": 0
}
]
}
}

View File

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`\`metaplex verify_token_metadata\` invalidates ../__fixtures__/invalidSchema/invalid-address.json 1`] = `"does not match pattern \\"[1-9A-HJ-NP-Za-km-z]{32,44}\\""`;
exports[`\`metaplex verify_token_metadata\` invalidates ../__fixtures__/invalidSchema/invalid-shares.json 1`] = `"must be strictly greater than 0"`;

View File

@ -0,0 +1,103 @@
import fs from 'fs';
import path from 'path';
import log from 'loglevel';
import {
verifyTokenMetadata,
verifyAggregateShare,
verifyImageURL,
verifyConsistentShares,
verifyCreatorCollation,
} from '../index';
const getFiles = rootDir => {
const assets = fs.readdirSync(rootDir).map(file => path.join(rootDir, file));
return assets;
};
describe('`metaplex verify_token_metadata`', () => {
const spy = jest.spyOn(log, 'warn');
beforeEach(() => {
spy.mockClear();
});
it('catches mismatched assets', () => {
const mismatchedAssets = getFiles(
path.join(__dirname, '../__fixtures__/mismatchedAssets'),
);
expect(() =>
verifyTokenMetadata({ files: mismatchedAssets }),
).toThrowErrorMatchingInlineSnapshot(
`"number of png files (0) is different than the number of json files (1)"`,
);
});
const invalidSchemas = getFiles(
path.join(__dirname, '../__fixtures__/invalidSchema'),
);
invalidSchemas.forEach(invalidSchema => {
it(`invalidates ${path.relative(__dirname, invalidSchema)}`, () => {
expect(() =>
verifyTokenMetadata({
files: [invalidSchema, invalidSchema.replace('.json', '.png')],
}),
).toThrowErrorMatchingSnapshot();
});
});
it('throws on invalid share allocation', () => {
expect(() =>
verifyAggregateShare(
[{ address: 'some-solana-address', share: 80 }],
'placeholder-manifest-file',
),
).toThrowErrorMatchingInlineSnapshot(
`"Creator share for placeholder-manifest-file does not add up to 100, got: 80."`,
);
expect(() =>
verifyAggregateShare(
[
{ address: 'some-solana-address', share: 80 },
{
address: 'some-other-solana-address',
share: 19.9,
},
],
'placeholder-manifest-file',
),
).toThrowErrorMatchingInlineSnapshot(
`"Creator share for placeholder-manifest-file does not add up to 100, got: 99.9."`,
);
});
it('warns when using different image URIs', () => {
verifyImageURL(
'https://google.com?ext=png',
[{ uri: 'https://google.com?ext=png', type: 'image/png' }],
'0.json',
);
expect(spy).toHaveBeenCalledTimes(1);
});
it('warns when there are inconsistent share allocations', () => {
const collatedCreators = new Map([
['some-solana-address', { shares: new Set([70]), tokenCount: 10 }],
]);
verifyCreatorCollation(
[{ address: 'some-solana-address', share: 80 }],
collatedCreators,
'0.json',
);
expect(spy).toHaveBeenCalledTimes(1);
});
it('warns when there are inconsistent creator allocations', () => {
const collatedCreators = new Map([
['some-solana-address', { shares: new Set([80]), tokenCount: 10 }],
['some-other-solana-address', { shares: new Set([80]), tokenCount: 20 }],
]);
verifyConsistentShares(collatedCreators);
expect(spy).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,163 @@
import path from 'path';
import log from 'loglevel';
import { validate } from 'jsonschema';
import { EXTENSION_JSON, EXTENSION_PNG } from '../../helpers/constants';
import tokenMetadataJsonSchema from './token-metadata.schema.json';
type TokenMetadata = {
image: string;
properties: {
files: { uri: string; type: string }[];
creators: { address: string; share: number }[];
};
};
export const verifyAssets = ({ files, uploadElementsCount }) => {
const pngFileCount = files.filter(it => {
return it.endsWith(EXTENSION_PNG);
}).length;
const jsonFileCount = files.filter(it => {
return it.endsWith(EXTENSION_JSON);
}).length;
const parsedNumber = parseInt(uploadElementsCount, 10);
const elemCount = parsedNumber ?? pngFileCount;
if (pngFileCount !== jsonFileCount) {
throw new Error(
`number of png files (${pngFileCount}) is different than the number of json files (${jsonFileCount})`,
);
}
if (elemCount < pngFileCount) {
throw new Error(
`max number (${elemCount}) cannot be smaller than the number of elements in the source folder (${pngFileCount})`,
);
}
log.info(`Verifying token metadata for ${pngFileCount} (png+json) pairs`);
};
export const verifyAggregateShare = (
creators: TokenMetadata['properties']['creators'],
manifestFile,
) => {
const aggregateShare = creators
.map(creator => creator.share)
.reduce((memo, share) => {
return memo + share;
}, 0);
// Check that creator share adds up to 100
if (aggregateShare !== 100) {
throw new Error(
`Creator share for ${manifestFile} does not add up to 100, got: ${aggregateShare}.`,
);
}
};
type CollatedCreators = Map<
string,
{ shares: Set<number>; tokenCount: number }
>;
export const verifyCreatorCollation = (
creators: TokenMetadata['properties']['creators'],
collatedCreators: CollatedCreators,
manifestFile: string,
) => {
for (const { address, share } of creators) {
if (collatedCreators.has(address)) {
const creator = collatedCreators.get(address);
creator.shares.add(share);
if (creator.shares.size > 1) {
log.warn(
`The creator share for ${address} in ${manifestFile} is different than the share declared for a previous token. This means at least one token is inconsistently configured, but we will continue. `,
);
}
creator.tokenCount += 1;
} else {
collatedCreators.set(address, {
tokenCount: 1,
shares: new Set([share]),
});
}
}
};
export const verifyImageURL = (image, files, manifestFile) => {
const expectedImagePath = `image${EXTENSION_PNG}`;
if (image !== expectedImagePath) {
// We _could_ match against this in the JSON schema validation, but it is totally valid to have arbitrary URLs to images here.
// The downside, though, is that those images will not get uploaded to Arweave since they're not on-disk.
log.warn(`We expected the \`image\` property in ${manifestFile} to be ${expectedImagePath}.
This will still work properly (assuming the URL is valid!), however, this image will not get uploaded to Arweave through the \`metaplex upload\` command.
If you want us to take care of getting this into Arweave, make sure to set \`image\`: "${expectedImagePath}"
The \`metaplex upload\` command will automatically substitute this URL with the Arweave URL location.
`);
}
const pngFiles = files.filter(file => file.type === 'image/png');
if (pngFiles.length === 0 || !pngFiles.some(file => file.uri === image)) {
throw new Error(
`At least one entry with the \`image/png\` type in the \`properties.files\` array is expected to match the \`image\` property.`,
);
}
};
export const verifyConsistentShares = (collatedCreators: CollatedCreators) => {
// We expect all creators to have been added to the same amount of tokens
const tokenCountSet = new Set<number>();
for (const [address, collation] of collatedCreators.entries()) {
tokenCountSet.add(collation.tokenCount);
if (tokenCountSet.size > 1) {
log.warn(
`We found that ${address} was added to more tokens than other creators.`,
);
}
}
};
export const verifyMetadataManifests = ({ files }) => {
const manifestFiles = files.filter(
file => path.extname(file) === EXTENSION_JSON,
);
// Used to keep track of the share allocations for individual creators
// We will send a warning if we notice discrepancies across the entire collection.
const collatedCreators: CollatedCreators = new Map();
// Do manifest-specific stuff here
for (const manifestFile of manifestFiles) {
// Check the overall schema shape. This is a non-exhaustive check, but guarantees the bare minimum needed for the rest of the commands to succeed.
const tokenMetadata = require(manifestFile) as TokenMetadata;
validate(tokenMetadata, tokenMetadataJsonSchema, { throwError: true });
const {
properties: { creators },
} = tokenMetadata;
verifyAggregateShare(creators, manifestFile);
verifyCreatorCollation(creators, collatedCreators, manifestFile);
// Check that the `image` and at least one of the files has a URI matching the index of this token.
const {
image,
properties: { files },
} = tokenMetadata;
verifyImageURL(image, files, manifestFile);
}
verifyConsistentShares(collatedCreators);
};
export const verifyTokenMetadata = ({
files,
uploadElementsCount = null,
}): Boolean => {
// Will we need to deal with the cache?
verifyAssets({ files, uploadElementsCount });
verifyMetadataManifests({ files });
return true;
};

View File

@ -0,0 +1,67 @@
{
"title": "Token Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Identifies the asset to which this token represents"
},
"description": {
"type": "string",
"description": "Describes the asset to which this token represents"
},
"image": {
"type": "string",
"description": "A URI pointing to a resource with mime type image/* representing the asset to which this token represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive."
},
"external_url": {
"type": "string",
"description": "A URI pointing to an external resource that will take user outside of the platform."
},
"seller_fee_basis_points": {
"type": "number",
"description": "Royalties percentage awarded to creators, represented as a 'basis point' (i.e., multiple the percentage by 100: 75% = 7500)",
"minimum": 0,
"maximum": 10000
},
"properties": {
"type": "object",
"description": "Arbitrary properties. Values may be strings, numbers, object or arrays.",
"properties": {
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"uri": { "type": "string" },
"type": {
"type": "string",
"description": "The MIME type for this file"
}
}
}
},
"creators": {
"type": "array",
"description": "Contains list of creators, each with Solana address and share of the NFT",
"items": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "A Solana address",
"pattern": "[1-9A-HJ-NP-Za-km-z]{32,44}"
},
"share": {
"type": "number",
"description": "Percentage of royalties to send to this address, represented as a percentage (0-100). The sum of all shares must equal 100",
"exclusiveMinimum": 0,
"maximum": 100
}
}
}
}
}
}
}
}

View File

@ -14,6 +14,7 @@
"noLib": false,
"preserveConstEnums": true,
"suppressImplicitAnyIndexErrors": true,
"resolveJsonModule": true,
"lib": ["dom", "es2019"]
},
"exclude": ["node_modules", "typings/browser", "typings/browser.d.ts"],

View File

@ -24,7 +24,7 @@
"watch-css": "less-watch-compiler src/ dist/lib/",
"watch-css-src": "less-watch-compiler src/ src/",
"watch": "tsc --watch",
"test": "jest test",
"test": "jest test --passWithNoTests",
"clean": "rm -rf dist",
"prepare": "run-s clean build",
"format:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|css|md)\""

View File

@ -44,7 +44,7 @@
"build": "next build",
"export": "next export -o ../../build/web",
"start:prod": "next start",
"test": "jest",
"test": "jest --passWithNoTests",
"deploy:ar": "yarn export && arweave deploy-dir ../../build/web --key-file ",
"deploy:gh": "yarn export && gh-pages -d ../../build/web --repo https://github.com/metaplex-foundation/metaplex -t true",
"deploy": "cross-env ASSET_PREFIX=/metaplex/ yarn build && yarn deploy:gh",

File diff suppressed because it is too large Load Diff