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:
parent
2e4817fe81
commit
048ddd6bac
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
14.17.0
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -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"`;
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
"noLib": false,
|
||||
"preserveConstEnums": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["dom", "es2019"]
|
||||
},
|
||||
"exclude": ["node_modules", "typings/browser", "typings/browser.d.ts"],
|
||||
|
|
|
@ -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)\""
|
||||
|
|
|
@ -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",
|
||||
|
|
718
js/yarn.lock
718
js/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue