From 04d7aad1ca9d7543c0cd8133ab5337cc8209bf15 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 13:42:07 -0400 Subject: [PATCH 1/6] feat: make the corpus executable with five clone-and-run starters Every page here describes a call and none of them is executable, so a reader who wants to know whether the corpus is right has to build the harness first. Five directories now sit under starters/, each cloned and run on its own: two read the hosted API with no key at all, and three sign on WAX testnet, where the V2 contracts run. The two signing variables are the contract the whole set turns on. WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are spelled that way in every starter, every README, and the workflow environment, and a starter missing either prints which one it wanted, signs nothing, and exits zero. A clone with no keys therefore runs green and says why instead of failing at a signature nobody asked for. The residual risk is stated rather than engineered away. The key signs on a chain with no value and the collections are disposable, so the worst case is junk minted into a throwaway collection, and the configuration that leaks is a repository secret without a fork guard. The lint ignore widens with them. Each starter installs into its own node_modules, and a top-level-only ignore would put every dependency's README under the corpus rule set the moment a contributor runs the tool. --- .markdownlint-cli2.jsonc | 9 +- starters/create-collection/.gitignore | 1 + starters/create-collection/LICENSE | 21 ++ starters/create-collection/README.md | 64 ++++++ starters/create-collection/package-lock.json | 192 ++++++++++++++++ starters/create-collection/package.json | 20 ++ starters/create-collection/src/collection.js | 95 ++++++++ starters/create-collection/src/credentials.js | 42 ++++ starters/create-collection/src/index.js | 74 +++++++ starters/create-collection/src/session.js | 31 +++ .../test/create-collection.test.js | 123 +++++++++++ starters/list-a-sale/.gitignore | 1 + starters/list-a-sale/LICENSE | 21 ++ starters/list-a-sale/README.md | 71 ++++++ starters/list-a-sale/package-lock.json | 205 ++++++++++++++++++ starters/list-a-sale/package.json | 21 ++ starters/list-a-sale/src/credentials.js | 42 ++++ starters/list-a-sale/src/index.js | 84 +++++++ starters/list-a-sale/src/listing.js | 150 +++++++++++++ starters/list-a-sale/src/session.js | 31 +++ starters/list-a-sale/test/list-a-sale.test.js | 117 ++++++++++ starters/mint-asset/.gitignore | 1 + starters/mint-asset/LICENSE | 21 ++ starters/mint-asset/README.md | 69 ++++++ starters/mint-asset/package-lock.json | 192 ++++++++++++++++ starters/mint-asset/package.json | 20 ++ starters/mint-asset/src/credentials.js | 42 ++++ starters/mint-asset/src/index.js | 85 ++++++++ starters/mint-asset/src/mint.js | 143 ++++++++++++ starters/mint-asset/src/session.js | 31 +++ starters/mint-asset/test/mint-asset.test.js | 130 +++++++++++ starters/read-assets/.gitignore | 1 + starters/read-assets/LICENSE | 21 ++ starters/read-assets/README.md | 42 ++++ starters/read-assets/package-lock.json | 28 +++ starters/read-assets/package.json | 18 ++ starters/read-assets/src/assets.js | 103 +++++++++ starters/read-assets/src/index.js | 33 +++ .../read-assets/test/asset-row.fixture.json | 31 +++ starters/read-assets/test/read-assets.test.js | 74 +++++++ starters/storefront-read/.gitignore | 1 + starters/storefront-read/LICENSE | 21 ++ starters/storefront-read/README.md | 42 ++++ starters/storefront-read/package-lock.json | 40 ++++ starters/storefront-read/package.json | 18 ++ starters/storefront-read/src/index.js | 33 +++ starters/storefront-read/src/sales.js | 106 +++++++++ .../test/sale-row.fixture.json | 38 ++++ .../test/storefront-read.test.js | 77 +++++++ 49 files changed, 2875 insertions(+), 1 deletion(-) create mode 100644 starters/create-collection/.gitignore create mode 100644 starters/create-collection/LICENSE create mode 100644 starters/create-collection/README.md create mode 100644 starters/create-collection/package-lock.json create mode 100644 starters/create-collection/package.json create mode 100644 starters/create-collection/src/collection.js create mode 100644 starters/create-collection/src/credentials.js create mode 100644 starters/create-collection/src/index.js create mode 100644 starters/create-collection/src/session.js create mode 100644 starters/create-collection/test/create-collection.test.js create mode 100644 starters/list-a-sale/.gitignore create mode 100644 starters/list-a-sale/LICENSE create mode 100644 starters/list-a-sale/README.md create mode 100644 starters/list-a-sale/package-lock.json create mode 100644 starters/list-a-sale/package.json create mode 100644 starters/list-a-sale/src/credentials.js create mode 100644 starters/list-a-sale/src/index.js create mode 100644 starters/list-a-sale/src/listing.js create mode 100644 starters/list-a-sale/src/session.js create mode 100644 starters/list-a-sale/test/list-a-sale.test.js create mode 100644 starters/mint-asset/.gitignore create mode 100644 starters/mint-asset/LICENSE create mode 100644 starters/mint-asset/README.md create mode 100644 starters/mint-asset/package-lock.json create mode 100644 starters/mint-asset/package.json create mode 100644 starters/mint-asset/src/credentials.js create mode 100644 starters/mint-asset/src/index.js create mode 100644 starters/mint-asset/src/mint.js create mode 100644 starters/mint-asset/src/session.js create mode 100644 starters/mint-asset/test/mint-asset.test.js create mode 100644 starters/read-assets/.gitignore create mode 100644 starters/read-assets/LICENSE create mode 100644 starters/read-assets/README.md create mode 100644 starters/read-assets/package-lock.json create mode 100644 starters/read-assets/package.json create mode 100644 starters/read-assets/src/assets.js create mode 100644 starters/read-assets/src/index.js create mode 100644 starters/read-assets/test/asset-row.fixture.json create mode 100644 starters/read-assets/test/read-assets.test.js create mode 100644 starters/storefront-read/.gitignore create mode 100644 starters/storefront-read/LICENSE create mode 100644 starters/storefront-read/README.md create mode 100644 starters/storefront-read/package-lock.json create mode 100644 starters/storefront-read/package.json create mode 100644 starters/storefront-read/src/index.js create mode 100644 starters/storefront-read/src/sales.js create mode 100644 starters/storefront-read/test/sale-row.fixture.json create mode 100644 starters/storefront-read/test/storefront-read.test.js diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 279e55c..69dc726 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -3,7 +3,14 @@ // everything else is a preference this corpus does not hold. { "globs": ["**/*.md"], - "ignores": ["node_modules", ".git", ".github/PULL_REQUEST_TEMPLATE.md"], + + // Recursive, not top-level. Each starter installs its dependencies into + // its own node_modules, and a bare "node_modules" would leave every + // dependency's README under this rule set the moment a contributor runs + // the tool after an install. Continuous integration never sees them, + // because the job that runs this installs nothing, so the mismatch would + // land only on the reader running it locally. + "ignores": ["**/node_modules", "**/.git", ".github/PULL_REQUEST_TEMPLATE.md"], "config": { // Only the rules below run. "default": false, diff --git a/starters/create-collection/.gitignore b/starters/create-collection/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/starters/create-collection/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/starters/create-collection/LICENSE b/starters/create-collection/LICENSE new file mode 100644 index 0000000..c5d175b --- /dev/null +++ b/starters/create-collection/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) atomicassets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/starters/create-collection/README.md b/starters/create-collection/README.md new file mode 100644 index 0000000..972083f --- /dev/null +++ b/starters/create-collection/README.md @@ -0,0 +1,64 @@ +# Create a collection on WAX testnet + +Signs `createcol` for a throwaway collection named after the signing account, then reads the collection back through the testnet API. This is the first write in the ladder: `mint-asset` mints into a collection this starter made, and `list-a-sale` lists what that minted. + +## The environment contract + +Two variables, and no other spelling of them: + +| Variable | Holds | +| --- | --- | +| `WAX_TESTNET_ACTOR` | the account that signs, pays the RAM, and authors the collection | +| `WAX_TESTNET_PRIVATE_KEY` | that account's `active` key, in WIF or `PVT_K1_` form | + +When either is absent or blank the starter prints which one it wanted, signs nothing, and exits zero. A clone with no keys therefore runs green and says why, and so does the read-only arm of this repository's own checks. + +``` +$ node src/index.js +WAX_TESTNET_PRIVATE_KEY is not set, so this starter signed nothing. Set both to run it against WAX testnet. +``` + +## Run it + +``` +npm install +WAX_TESTNET_ACTOR=mycreator11 WAX_TESTNET_PRIVATE_KEY=yourkey node src/index.js +``` + +A run that signs prints the collection it chose, the transaction the chain accepted, and the row the API serves once the indexer catches up: + +``` +Signing createcol for mycreaqm3wtb as mycreator11@active on WAX testnet. +The chain accepted transaction 6f0c...e21a. +The API now serves mycreaqm3wtb, authored by mycreator11. +``` + +## What it signs + +`ActionBuilder` from `@atomichub/atomicassets` is synchronous and holds no session. `createcol` returns one `{ account, name, data }` object, and the command attaches the authorization its session carries, so building an action and signing one stay separate steps. The session itself is a WharfKit `Session` on `Chains.WAXTestnet` with the private-key plugin holding the key in memory, which is the shape for a script and never for a browser. WAX testnet is the chain because that is where the V2 contracts run. + +The collection name is twelve characters: six carried over from the actor so a reader can tell whose it is, and six from `randomBytes` so a second run does not collide with the first. Twelve characters with no dot is the naming path that needs no co-signer. A name that happens to be a registered account still needs that account to sign, and the chain says so. + +`market_fee` is `0` and passes through `ActionBuilder`'s finite-number check before an action exists. A `NaN` there would reach the signer as `null`, because JSON has no form for it, and the mistake would be invisible by the time the chain saw it. + +A committed transaction and an indexed row are two facts. The command polls the testnet API for thirty seconds and fails if the row never arrives, rather than reporting a success the reader cannot see. + +## The tests + +``` +npm test +``` + +Ten propositions run under `node --test`, none of them signing and none needing a key. Two spawn the command with both variables stripped from its environment and assert it exits zero naming the missing one, which is the same path a reader without keys takes. The rest cover the blank-value rule, the message's singular and plural forms, the derived name's shape and its per-run entropy, the entropy floor, the composed `createcol` action field by field, and the market-fee guard. + +The two spawning propositions delete the variables from the child's environment rather than reading the ambient one, so they prove the skip path even when a run does hold keys. + +## Residual risk + +The key this starter reads signs on a chain with no value, the account holds no mainnet authority, and the collection is disposable, so the worst case is junk written into a throwaway collection. Use a testnet account created for this and nothing else, and never a key that also exists on mainnet. + +The key reaches the process through the environment and is held in memory unencrypted by the private-key plugin. Keep it out of the shell history and out of any committed file. In this repository's checks the two values live in a GitHub Actions environment and the signing arm never runs for a pull request, which is the guard that matters: a repository secret with no fork guard is the configuration that leaks, not the chain the key signs on. + +## License + +MIT, see LICENSE. diff --git a/starters/create-collection/package-lock.json b/starters/create-collection/package-lock.json new file mode 100644 index 0000000..b7e23ee --- /dev/null +++ b/starters/create-collection/package-lock.json @@ -0,0 +1,192 @@ +{ + "name": "create-collection", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "create-collection", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@atomichub/atomicassets": "2.1.1", + "@wharfkit/session": "1.6.1", + "@wharfkit/wallet-plugin-privatekey": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicassets": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicassets/-/atomicassets-2.1.1.tgz", + "integrity": "sha512-2H+6kNUP1aU6lkqmCln00bsRR+Ej9uPxWL6vEzA47coEI06tMnHuewQM345W+XXgud/igb+M/jxMkDnDbWV0kg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@wharfkit/abicache": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@wharfkit/abicache/-/abicache-1.2.4.tgz", + "integrity": "sha512-DeIPotkMyOXZgLFOmmTXXbynNE1OF2bbEQlaUrqB1kGmNL3WJB1Y09NZ3huvFJylfqD928ZqIdGxB4KJ3iIcGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@wharfkit/antelope": "^1.0.2", + "@wharfkit/signing-request": "^3.1.0", + "pako": "^2.0.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@wharfkit/antelope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@wharfkit/antelope/-/antelope-1.2.0.tgz", + "integrity": "sha512-9q0nvM8yUtjKTQlukKZODAhUN2S2/cfSlIYdh2mPnaOCSH8KOLJ2gYCPuQVvH1FE9AKu312lM5TzhkRccf1VjQ==", + "license": "BSD-3-Clause-No-Military-License", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "elliptic": "^6.5.4", + "hash.js": "^1.0.0", + "pako": "^2.1.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@wharfkit/common": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@wharfkit/common/-/common-1.5.0.tgz", + "integrity": "sha512-eqXkOy+vshcEzK8kED+EsoTPJjlBKHYglgV9CBnZQgIlGrWIRXWH4YaXH3W7EbI/nCRJCaNqxm5fC+pgpFcp8g==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@wharfkit/antelope": "^1.0.0" + } + }, + "node_modules/@wharfkit/session": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@wharfkit/session/-/session-1.6.1.tgz", + "integrity": "sha512-k6ntDGOe8bvD/Ps0erTPTFMdYVFrw5cRvPcEwxytlmRRcNV/M8xWcpCYWdmGDxa8QYqynf/hAkbVh1PSwRGl5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@wharfkit/abicache": "^1.2.1", + "@wharfkit/antelope": "^1.0.11", + "@wharfkit/common": "^1.2.0", + "@wharfkit/signing-request": "^3.1.0", + "pako": "^2.0.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@wharfkit/signing-request": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@wharfkit/signing-request/-/signing-request-3.4.0.tgz", + "integrity": "sha512-WstXfmR9i5pKaYXDUwNFNCgBIvN6u5IRGWSfj5O3XzthbtJUmRoJNtjGMaNnUqZ1MMx5YY4/JpY3b2e6LbpXLw==", + "license": "MIT", + "dependencies": { + "@wharfkit/antelope": "^1.1.1", + "tslib": "^2.0.3" + } + }, + "node_modules/@wharfkit/wallet-plugin-privatekey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wharfkit/wallet-plugin-privatekey/-/wallet-plugin-privatekey-1.1.0.tgz", + "integrity": "sha512-45LPj7AOVDm4RugDEhy0fnQX/BcMffeJPjGUCUrLazJ2S0Sti8nNk4nqiJqyme84c/0gq7d65vvwlmVfGtPVEg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@wharfkit/session": "^1.1.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/starters/create-collection/package.json b/starters/create-collection/package.json new file mode 100644 index 0000000..7499f55 --- /dev/null +++ b/starters/create-collection/package.json @@ -0,0 +1,20 @@ +{ + "name": "create-collection", + "version": "1.0.0", + "private": true, + "description": "Sign createcol on WAX testnet for a throwaway collection derived from the signing account", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "@atomichub/atomicassets": "2.1.1", + "@wharfkit/session": "1.6.1", + "@wharfkit/wallet-plugin-privatekey": "1.1.0" + } +} diff --git a/starters/create-collection/src/collection.js b/starters/create-collection/src/collection.js new file mode 100644 index 0000000..06fa8c0 --- /dev/null +++ b/starters/create-collection/src/collection.js @@ -0,0 +1,95 @@ +/** + * Derives a throwaway collection name from the signing account and builds the + * `createcol` action for it. Neither function needs a session, so both are + * callable, and testable, without a key. + */ +import { randomBytes } from 'node:crypto'; + +import { ActionBuilder, createAttributeMap, explorerApiForNetwork } from '@atomichub/atomicassets'; + +/** The AtomicAssets contract account. It carries this name on every chain. */ +export const ATOMICASSETS = 'atomicassets'; + +/** + * The characters an Antelope name may hold, minus the dot. A dot in a + * collection name hands the naming check to the account matching the suffix, + * which would need a second signer, so the derived name below carries none. + */ +const NAME_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz12345'; + +/** + * Builds a 12-character collection name: six characters carried over from the + * actor so a reader can tell whose it is, and six from entropy so a second run + * does not collide with the first. + * + * Twelve characters with no dot is the third path of the contract's naming + * check, the one that needs no co-signer. The first path still applies: a name + * that happens to be a registered account needs that account to co-sign, and + * the chain rejects the transaction naming the missing authority. + * + * @param {string} actor account the collection is derived from + * @param {Uint8Array} entropy at least six bytes + * @returns {string} + */ +export function deriveCollectionName(actor, entropy = randomBytes(6)) { + if (entropy.length < 6) { + throw new Error(`entropy holds ${entropy.length} bytes, and a derived name needs six`); + } + + const carried = [...actor] + .filter((character) => NAME_CHARACTERS.includes(character)) + .slice(0, 6) + .join('') + .padEnd(6, 'a'); + + // The modulo bias across 31 characters is irrelevant here: the tail exists so + // two runs of a throwaway starter do not collide, not to be unguessable. + const tail = [...entropy] + .slice(0, 6) + .map((byte) => NAME_CHARACTERS[byte % NAME_CHARACTERS.length]) + .join(''); + + return `${carried}${tail}`; +} + +/** + * The `createcol` action, authorization left off. `ActionBuilder` is + * synchronous and holds no session: it returns one `{ account, name, data }` + * object, and the caller attaches the authorization its session carries. + * + * `market_fee` is checked as a finite number before the action exists, because + * a `NaN` has no JSON form and would reach the signer as `null`. + * + * @param {string} actor collection author, also the only authorized account + * @param {string} collectionName name from deriveCollectionName + * @param {number} marketFee share of a sale the collection takes, 0 to 0.15 + * @returns {{account: string, name: string, data: object}} + */ +export function buildCreateCollection(actor, collectionName, marketFee = 0) { + const data = createAttributeMap({ name: 'Starter collection' }, { name: 'string' }); + + return new ActionBuilder(ATOMICASSETS).createcol(actor, collectionName, true, [actor], [], marketFee, data); +} + +/** + * Reads the collection back through the testnet API. A committed transaction + * and an indexed row are two facts, so this answers null until the indexer has + * caught up, and the caller decides how long to wait. + * + * @param {string} collectionName name to read + * @param {object} api explorer client, overridable so a test can point elsewhere + * @returns {Promise} the collection row, or null while it is not indexed + */ +export async function readCollection(collectionName, api = explorerApiForNetwork('wax-testnet')) { + try { + return await api.getCollection(collectionName); + } catch (error) { + // The API answers a missing collection with an error rather than an empty + // body. Anything that is not an API error is a real failure and is rethrown. + if (error?.isApiError === true) { + return null; + } + + throw error; + } +} diff --git a/starters/create-collection/src/credentials.js b/starters/create-collection/src/credentials.js new file mode 100644 index 0000000..491a187 --- /dev/null +++ b/starters/create-collection/src/credentials.js @@ -0,0 +1,42 @@ +/** + * The two variables every signing starter reads, and the message it prints + * when either is absent. + * + * These two spellings are the contract. The starters, their READMEs, and the + * workflow environment that runs them use `WAX_TESTNET_ACTOR` and + * `WAX_TESTNET_PRIVATE_KEY` and no other spelling, so a half-configured + * environment is never read as a configured one. + * + * Each signing starter carries its own copy of this file rather than sharing + * one. A starter is meant to be cloned as a single directory and run, so a + * shared module would be a dependency a reader cannot see. The three copies + * are identical; keep them that way. + */ +export const CREDENTIALS = ['WAX_TESTNET_ACTOR', 'WAX_TESTNET_PRIVATE_KEY']; + +/** + * The credential names that are absent or blank, in the order above. A + * variable set to whitespace counts as absent: an empty secret in a + * continuous-integration environment arrives as an empty string, and treating + * it as a value produces a signing failure that names nothing useful. + * + * @param {Record} env process environment to read + * @returns {string[]} + */ +export function missingCredentials(env) { + return CREDENTIALS.filter((name) => (env[name] ?? '').trim() === ''); +} + +/** + * The line printed on the skip path. It names the variables that are missing + * and says what did not happen, so a reader who cloned without keys gets a + * green run and a legible reason. + * + * @param {string[]} missing names from missingCredentials + * @returns {string} + */ +export function skipMessage(missing) { + const verb = missing.length === 1 ? 'is' : 'are'; + + return `${missing.join(' and ')} ${verb} not set, so this starter signed nothing. Set both to run it against WAX testnet.`; +} diff --git a/starters/create-collection/src/index.js b/starters/create-collection/src/index.js new file mode 100644 index 0000000..8580935 --- /dev/null +++ b/starters/create-collection/src/index.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * Signs `createcol` on WAX testnet for a throwaway collection named after the + * signing account, then reads the collection back through the testnet API. + * + * Usage: WAX_TESTNET_ACTOR=... WAX_TESTNET_PRIVATE_KEY=... node src/index.js + */ +import { setTimeout as sleep } from 'node:timers/promises'; + +import { buildCreateCollection, deriveCollectionName, readCollection } from './collection.js'; +import { missingCredentials, skipMessage } from './credentials.js'; +import { openSession } from './session.js'; + +/** Bounds the wait for the indexer. A commit and an indexed row are two facts. */ +const INDEX_ATTEMPTS = 15; +const INDEX_DELAY_MS = 2000; + +/** The id the node returned, or the one the session resolved before broadcast. */ +function transactionId(result) { + const broadcast = result.response?.transaction_id; + + if (typeof broadcast === 'string') { + return broadcast; + } + + const resolved = result.resolved?.transaction?.id; + + return resolved === undefined ? '(none returned)' : String(resolved); +} + +async function main() { + const missing = missingCredentials(process.env); + + if (missing.length > 0) { + console.log(skipMessage(missing)); + + return; + } + + const actor = process.env.WAX_TESTNET_ACTOR.trim(); + const collectionName = deriveCollectionName(actor); + const action = buildCreateCollection(actor, collectionName); + + console.log(`Signing createcol for ${collectionName} as ${actor}@active on WAX testnet.`); + + const session = openSession(process.env); + const result = await session.transact({ + action: { ...action, authorization: [session.permissionLevel] }, + }); + + console.log(`The chain accepted transaction ${transactionId(result)}.`); + + for (let attempt = 1; attempt <= INDEX_ATTEMPTS; attempt += 1) { + const collection = await readCollection(collectionName); + + if (collection !== null) { + console.log(`The API now serves ${collection.collection_name}, authored by ${collection.author}.`); + + return; + } + + await sleep(INDEX_DELAY_MS); + } + + throw new Error( + `the chain accepted the transaction and the API had not served ${collectionName} ` + + `after ${(INDEX_ATTEMPTS * INDEX_DELAY_MS) / 1000} seconds`, + ); +} + +main().catch((error) => { + console.error(`createcol failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/starters/create-collection/src/session.js b/starters/create-collection/src/session.js new file mode 100644 index 0000000..6d9dc7b --- /dev/null +++ b/starters/create-collection/src/session.js @@ -0,0 +1,31 @@ +/** + * Builds the WharfKit session the action below signs through. The key is read + * from the environment and held in memory by the private-key plugin, which is + * the shape for a script or a continuous-integration job and never for a + * browser. + * + * Each signing starter carries its own copy of this file rather than sharing + * one. A starter is meant to be cloned as a single directory and run, so a + * shared module would be a dependency a reader cannot see. The three copies + * are identical; keep them that way. + */ +import { Chains, Session } from '@wharfkit/session'; +import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey'; + +/** + * WAX testnet is where the V2 contracts run, so it is the chain every starter + * here signs against. The chain id is what a signature commits to: a session + * pointed at the wrong chain produces a transaction the target rejects rather + * than a network error. + * + * @param {Record} env process environment holding the credentials + * @returns {Session} + */ +export function openSession(env) { + return new Session({ + actor: env.WAX_TESTNET_ACTOR, + permission: 'active', + chain: Chains.WAXTestnet, + walletPlugin: new WalletPluginPrivateKey(env.WAX_TESTNET_PRIVATE_KEY), + }); +} diff --git a/starters/create-collection/test/create-collection.test.js b/starters/create-collection/test/create-collection.test.js new file mode 100644 index 0000000..8f8b960 --- /dev/null +++ b/starters/create-collection/test/create-collection.test.js @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +import { buildCreateCollection, deriveCollectionName } from '../src/collection.js'; +import { CREDENTIALS, missingCredentials, skipMessage } from '../src/credentials.js'; + +const run = promisify(execFile); +const entrypoint = new URL('../src/index.js', import.meta.url).pathname; + +/** + * Runs the command with the two credentials removed from its environment, + * whatever the ambient environment holds. A run inside the signing arm of + * continuous integration has both variables set, and a skip-path proposition + * that read them would sign instead of proving the skip. + * + * @param {Record} overrides variables to add back + * @returns {Promise<{stdout: string, stderr: string}>} + */ +function runWithout(overrides = {}) { + const env = { ...process.env }; + + for (const name of CREDENTIALS) { + delete env[name]; + } + + return run(process.execPath, [entrypoint], { env: { ...env, ...overrides } }); +} + +test('the command exits zero and names both variables when neither is set', async () => { + // execFile rejects on a non-zero exit, so reaching the assertion is the + // exit-zero half of the proposition. + const { stdout } = await runWithout(); + + assert.match(stdout, /WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are not set/); + assert.match(stdout, /signed nothing/); +}); + +test('the command names only the variable that is missing', async () => { + const { stdout } = await runWithout({ WAX_TESTNET_ACTOR: 'starterdemo1' }); + + assert.match(stdout, /^WAX_TESTNET_PRIVATE_KEY is not set/); + assert.equal(stdout.includes('WAX_TESTNET_ACTOR is not set'), false); +}); + +test('a variable set to whitespace counts as absent', () => { + assert.deepEqual(missingCredentials({ WAX_TESTNET_ACTOR: ' ', WAX_TESTNET_PRIVATE_KEY: 'PVT_K1_xxx' }), [ + 'WAX_TESTNET_ACTOR', + ]); + assert.deepEqual( + missingCredentials({ WAX_TESTNET_ACTOR: 'starterdemo1', WAX_TESTNET_PRIVATE_KEY: 'PVT_K1_xxx' }), + [], + ); +}); + +test('the skip message agrees in number with the variables it names', () => { + assert.equal( + skipMessage(['WAX_TESTNET_PRIVATE_KEY']), + 'WAX_TESTNET_PRIVATE_KEY is not set, so this starter signed nothing. Set both to run it against WAX testnet.', + ); + assert.equal( + skipMessage(CREDENTIALS), + 'WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are not set, so this starter signed nothing. ' + + 'Set both to run it against WAX testnet.', + ); +}); + +test('a derived name is twelve characters of the name alphabet and carries no dot', () => { + const name = deriveCollectionName('starterdemo1'); + + assert.equal(name.length, 12); + assert.match(name, /^[a-z1-5]{12}$/); +}); + +test('a derived name carries the actor and pads one shorter than six characters', () => { + const entropy = Uint8Array.from([0, 0, 0, 0, 0, 0]); + + assert.equal(deriveCollectionName('starter.wam', entropy), 'starteaaaaaa'); + assert.equal(deriveCollectionName('ab', entropy), 'abaaaaaaaaaa'); +}); + +test('two derivations from one actor differ, so a second run does not collide', () => { + const first = deriveCollectionName('starterdemo1'); + const second = deriveCollectionName('starterdemo1'); + + assert.notEqual(first, second); + assert.equal(first.slice(0, 6), second.slice(0, 6)); +}); + +test('a derivation refuses entropy too short to fill the tail', () => { + assert.throws( + () => deriveCollectionName('starterdemo1', Uint8Array.from([1, 2, 3])), + /entropy holds 3 bytes, and a derived name needs six/, + ); +}); + +test('the action is createcol on atomicassets, authorizing the author and charging no market fee', () => { + const action = buildCreateCollection('starterdemo1', 'starterdemoa'); + + assert.deepEqual(action, { + account: 'atomicassets', + name: 'createcol', + data: { + author: 'starterdemo1', + collection_name: 'starterdemoa', + allow_notify: true, + authorized_accounts: ['starterdemo1'], + notify_accounts: [], + market_fee: 0, + data: [{ key: 'name', value: ['string', 'Starter collection'] }], + }, + }); +}); + +test('a market fee that is not a finite number fails before an action exists', () => { + // NaN has no JSON form, so an unguarded one reaches the signer as null and + // the mistake is gone before the chain can name it. + assert.throws( + () => buildCreateCollection('starterdemo1', 'starterdemoa', Number.NaN), + /market_fee NaN is not a finite number/, + ); +}); diff --git a/starters/list-a-sale/.gitignore b/starters/list-a-sale/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/starters/list-a-sale/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/starters/list-a-sale/LICENSE b/starters/list-a-sale/LICENSE new file mode 100644 index 0000000..c5d175b --- /dev/null +++ b/starters/list-a-sale/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) atomicassets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/starters/list-a-sale/README.md b/starters/list-a-sale/README.md new file mode 100644 index 0000000..bdc42ba --- /dev/null +++ b/starters/list-a-sale/README.md @@ -0,0 +1,71 @@ +# List a sale on WAX testnet + +Signs the AtomicMarket listing pair for one asset the signing account owns, then reads the sale back through the testnet API. Run the `mint-asset` starter first if the account owns nothing to list. + +## The environment contract + +Two variables, and no other spelling of them: + +| Variable | Holds | +| --- | --- | +| `WAX_TESTNET_ACTOR` | the account that signs and sells | +| `WAX_TESTNET_PRIVATE_KEY` | that account's `active` key, in WIF or `PVT_K1_` form | + +When either is absent or blank the starter prints which one it wanted, signs nothing, and exits zero. A clone with no keys therefore runs green and says why, and so does the read-only arm of this repository's own checks. + +``` +$ node src/index.js +WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are not set, so this starter signed nothing. Set both to run it against WAX testnet. +``` + +## Run it + +``` +npm install +WAX_TESTNET_ACTOR=mycreator11 WAX_TESTNET_PRIVATE_KEY=yourkey node src/index.js +WAX_TESTNET_ACTOR=mycreator11 WAX_TESTNET_PRIVATE_KEY=yourkey node src/index.js 2199024342156 "12.50000000 WAX" +``` + +With no arguments the asset is the newest one the API reports this account owning, and the price is `1.00000000 WAX`. A run that signs prints what it chose, the transaction the chain accepted, and the row the API serves once the indexer catches up: + +``` +Listing asset 2199024342156 at 1.00000000 WAX as mycreator11@active on WAX testnet. +The chain accepted transaction 90da...4b16. +The API now serves sale 3491027, offer 8812340, asking 1.00000000 WAX. +``` + +## What it signs + +`MarketActionBuilder.announceSaleActions` from `@atomichub/atomicmarket` emits the whole listing flow rather than one action at a time: `announcesale` on `atomicmarket`, then a `createoffer` on `atomicassets` carrying the memo `sale`. The order and that memo literal are the contract's requirements, and the composer is what keeps them out of the caller's hands. Assembling the pair by hand is how a listing ends up announced but inactive, or offered but dangling. + +`announcesale` moves nothing. The sale is a lazy-accept escrow: the asset stays in the seller's account until a buyer calls `purchasesale`, which accepts the offer and transfers the asset in the same transaction. That is why the offer is what activates the row, and why both actions go in one transaction. + +The composer checks nothing about the listing, by design: asset counts, symbol support, and marketplace registration are all chain state it is not handed. This starter holds the two lines it can hold without reading the chain. It refuses a listing naming other than exactly one asset, because AtomicMarket V2 removed bundle listings and asks for one sale per asset. And it refuses a listing price whose symbol does not match `settlement_symbol`, because a listing whose two symbols differ is a Delphi sale, which settles an oracle conversion of the price rather than the price itself. Both refusals happen before any action exists. + +`maker_marketplace` is the empty string, the contract's seeded default, which is always valid. Any other value has to name a marketplace already registered on chain. + +A committed transaction and an indexed row are two facts. The command polls the testnet API for thirty seconds and fails if the row never arrives, rather than reporting a success the reader cannot see. + +## The tests + +``` +npm test +``` + +Nine propositions run under `node --test`, none of them signing and none needing a key. Two spawn the command with both variables stripped from its environment and assert it exits zero naming the missing one, which is the same path a reader without keys takes. Three pin what the composer emits: the two actions and their order, the offer's memo and its empty return side, and the announcement's fields. The last four cover the bundle refusal, the two symbol refusals, and the quantity reader behind them. + +The composed order and memo are asserted against values the tests never set, so an upgrade of `@atomichub/atomicmarket` that changed either would redden here rather than on chain. + +The two spawning propositions delete the variables from the child's environment rather than reading the ambient one, so they prove the skip path even when a run does hold keys. + +## Residual risk + +The key this starter reads signs on a chain with no value, the account holds no mainnet authority, and the asset it lists is disposable, so the worst case is a junk listing on a testnet marketplace. Use a testnet account created for this and nothing else, and never a key that also exists on mainnet. + +The key reaches the process through the environment and is held in memory unencrypted by the private-key plugin. Keep it out of the shell history and out of any committed file. In this repository's checks the two values live in a GitHub Actions environment and the signing arm never runs for a pull request, which is the guard that matters: a repository secret with no fork guard is the configuration that leaks, not the chain the key signs on. + +A listing this starter leaves behind stays live until it is bought or cancelled, and the offer behind it holds the seller's RAM for as long as it stands. `cancelsale` closes both. + +## License + +MIT, see LICENSE. diff --git a/starters/list-a-sale/package-lock.json b/starters/list-a-sale/package-lock.json new file mode 100644 index 0000000..b9061e9 --- /dev/null +++ b/starters/list-a-sale/package-lock.json @@ -0,0 +1,205 @@ +{ + "name": "list-a-sale", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "list-a-sale", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@atomichub/atomicassets": "2.1.1", + "@atomichub/atomicmarket": "2.4.1", + "@wharfkit/session": "1.6.1", + "@wharfkit/wallet-plugin-privatekey": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicassets": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicassets/-/atomicassets-2.1.1.tgz", + "integrity": "sha512-2H+6kNUP1aU6lkqmCln00bsRR+Ej9uPxWL6vEzA47coEI06tMnHuewQM345W+XXgud/igb+M/jxMkDnDbWV0kg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicmarket": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicmarket/-/atomicmarket-2.4.1.tgz", + "integrity": "sha512-RnDfu4AmGMzQdOP/CoXcpX56vjNoZEbjbIICjabKloZH6o6xLLjwudk6VL9RleeHiY0i4TYeIJx8HVRriG16GA==", + "license": "MIT", + "dependencies": { + "@atomichub/atomicassets": "^2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@wharfkit/abicache": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@wharfkit/abicache/-/abicache-1.2.4.tgz", + "integrity": "sha512-DeIPotkMyOXZgLFOmmTXXbynNE1OF2bbEQlaUrqB1kGmNL3WJB1Y09NZ3huvFJylfqD928ZqIdGxB4KJ3iIcGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@wharfkit/antelope": "^1.0.2", + "@wharfkit/signing-request": "^3.1.0", + "pako": "^2.0.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@wharfkit/antelope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@wharfkit/antelope/-/antelope-1.2.0.tgz", + "integrity": "sha512-9q0nvM8yUtjKTQlukKZODAhUN2S2/cfSlIYdh2mPnaOCSH8KOLJ2gYCPuQVvH1FE9AKu312lM5TzhkRccf1VjQ==", + "license": "BSD-3-Clause-No-Military-License", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "elliptic": "^6.5.4", + "hash.js": "^1.0.0", + "pako": "^2.1.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@wharfkit/common": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@wharfkit/common/-/common-1.5.0.tgz", + "integrity": "sha512-eqXkOy+vshcEzK8kED+EsoTPJjlBKHYglgV9CBnZQgIlGrWIRXWH4YaXH3W7EbI/nCRJCaNqxm5fC+pgpFcp8g==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@wharfkit/antelope": "^1.0.0" + } + }, + "node_modules/@wharfkit/session": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@wharfkit/session/-/session-1.6.1.tgz", + "integrity": "sha512-k6ntDGOe8bvD/Ps0erTPTFMdYVFrw5cRvPcEwxytlmRRcNV/M8xWcpCYWdmGDxa8QYqynf/hAkbVh1PSwRGl5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@wharfkit/abicache": "^1.2.1", + "@wharfkit/antelope": "^1.0.11", + "@wharfkit/common": "^1.2.0", + "@wharfkit/signing-request": "^3.1.0", + "pako": "^2.0.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@wharfkit/signing-request": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@wharfkit/signing-request/-/signing-request-3.4.0.tgz", + "integrity": "sha512-WstXfmR9i5pKaYXDUwNFNCgBIvN6u5IRGWSfj5O3XzthbtJUmRoJNtjGMaNnUqZ1MMx5YY4/JpY3b2e6LbpXLw==", + "license": "MIT", + "dependencies": { + "@wharfkit/antelope": "^1.1.1", + "tslib": "^2.0.3" + } + }, + "node_modules/@wharfkit/wallet-plugin-privatekey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wharfkit/wallet-plugin-privatekey/-/wallet-plugin-privatekey-1.1.0.tgz", + "integrity": "sha512-45LPj7AOVDm4RugDEhy0fnQX/BcMffeJPjGUCUrLazJ2S0Sti8nNk4nqiJqyme84c/0gq7d65vvwlmVfGtPVEg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@wharfkit/session": "^1.1.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/starters/list-a-sale/package.json b/starters/list-a-sale/package.json new file mode 100644 index 0000000..ad80355 --- /dev/null +++ b/starters/list-a-sale/package.json @@ -0,0 +1,21 @@ +{ + "name": "list-a-sale", + "version": "1.0.0", + "private": true, + "description": "Sign the AtomicMarket listing pair on WAX testnet for one asset", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "@atomichub/atomicassets": "2.1.1", + "@atomichub/atomicmarket": "2.4.1", + "@wharfkit/session": "1.6.1", + "@wharfkit/wallet-plugin-privatekey": "1.1.0" + } +} diff --git a/starters/list-a-sale/src/credentials.js b/starters/list-a-sale/src/credentials.js new file mode 100644 index 0000000..491a187 --- /dev/null +++ b/starters/list-a-sale/src/credentials.js @@ -0,0 +1,42 @@ +/** + * The two variables every signing starter reads, and the message it prints + * when either is absent. + * + * These two spellings are the contract. The starters, their READMEs, and the + * workflow environment that runs them use `WAX_TESTNET_ACTOR` and + * `WAX_TESTNET_PRIVATE_KEY` and no other spelling, so a half-configured + * environment is never read as a configured one. + * + * Each signing starter carries its own copy of this file rather than sharing + * one. A starter is meant to be cloned as a single directory and run, so a + * shared module would be a dependency a reader cannot see. The three copies + * are identical; keep them that way. + */ +export const CREDENTIALS = ['WAX_TESTNET_ACTOR', 'WAX_TESTNET_PRIVATE_KEY']; + +/** + * The credential names that are absent or blank, in the order above. A + * variable set to whitespace counts as absent: an empty secret in a + * continuous-integration environment arrives as an empty string, and treating + * it as a value produces a signing failure that names nothing useful. + * + * @param {Record} env process environment to read + * @returns {string[]} + */ +export function missingCredentials(env) { + return CREDENTIALS.filter((name) => (env[name] ?? '').trim() === ''); +} + +/** + * The line printed on the skip path. It names the variables that are missing + * and says what did not happen, so a reader who cloned without keys gets a + * green run and a legible reason. + * + * @param {string[]} missing names from missingCredentials + * @returns {string} + */ +export function skipMessage(missing) { + const verb = missing.length === 1 ? 'is' : 'are'; + + return `${missing.join(' and ')} ${verb} not set, so this starter signed nothing. Set both to run it against WAX testnet.`; +} diff --git a/starters/list-a-sale/src/index.js b/starters/list-a-sale/src/index.js new file mode 100644 index 0000000..6ea9a7e --- /dev/null +++ b/starters/list-a-sale/src/index.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Signs the AtomicMarket listing pair on WAX testnet, announcing a sale for one + * asset and offering that asset to the market contract, then reads the sale + * back through the testnet API. + * + * Usage: WAX_TESTNET_ACTOR=... WAX_TESTNET_PRIVATE_KEY=... node src/index.js [asset_id] [price] + */ +import { setTimeout as sleep } from 'node:timers/promises'; + +import { missingCredentials, skipMessage } from './credentials.js'; +import { buildListing, DEFAULT_LISTING_PRICE, newestAsset, readSale } from './listing.js'; +import { openSession } from './session.js'; + +/** Bounds the wait for the indexer. A commit and an indexed row are two facts. */ +const INDEX_ATTEMPTS = 15; +const INDEX_DELAY_MS = 2000; + +/** The id the node returned, or the one the session resolved before broadcast. */ +function transactionId(result) { + const broadcast = result.response?.transaction_id; + + if (typeof broadcast === 'string') { + return broadcast; + } + + const resolved = result.resolved?.transaction?.id; + + return resolved === undefined ? '(none returned)' : String(resolved); +} + +async function main() { + const missing = missingCredentials(process.env); + + if (missing.length > 0) { + console.log(skipMessage(missing)); + + return; + } + + const actor = process.env.WAX_TESTNET_ACTOR.trim(); + const assetId = process.argv[2] ?? (await newestAsset(actor)); + const listingPrice = process.argv[3] ?? DEFAULT_LISTING_PRICE; + + if (assetId === null) { + throw new Error( + `${actor} owns no asset on WAX testnet. Run the mint-asset starter first, ` + + 'or name an asset id on the command line.', + ); + } + + const actions = buildListing(actor, [assetId], listingPrice); + + console.log(`Listing asset ${assetId} at ${listingPrice} as ${actor}@active on WAX testnet.`); + + const session = openSession(process.env); + const result = await session.transact({ + actions: actions.map((action) => ({ ...action, authorization: [session.permissionLevel] })), + }); + + console.log(`The chain accepted transaction ${transactionId(result)}.`); + + for (let attempt = 1; attempt <= INDEX_ATTEMPTS; attempt += 1) { + const sale = await readSale(actor, assetId); + + if (sale !== null) { + console.log(`The API now serves sale ${sale.sale_id}, offer ${sale.offer_id}, asking ${listingPrice}.`); + + return; + } + + await sleep(INDEX_DELAY_MS); + } + + throw new Error( + `the chain accepted the transaction and the API had not served a listed sale for asset ${assetId} ` + + `after ${(INDEX_ATTEMPTS * INDEX_DELAY_MS) / 1000} seconds`, + ); +} + +main().catch((error) => { + console.error(`The listing failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/starters/list-a-sale/src/listing.js b/starters/list-a-sale/src/listing.js new file mode 100644 index 0000000..3f96c00 --- /dev/null +++ b/starters/list-a-sale/src/listing.js @@ -0,0 +1,150 @@ +/** + * Composes the listing pair AtomicMarket needs and reads the result back. + * Nothing here needs a session, so all of it is callable, and testable, + * without a key. + */ +import { explorerApiForNetwork } from '@atomichub/atomicassets'; +import { MarketActionBuilder, marketApiForNetwork, SaleState } from '@atomichub/atomicmarket'; + +/** The two contract accounts. They carry these names on every chain. */ +export const ATOMICASSETS = 'atomicassets'; +export const ATOMICMARKET = 'atomicmarket'; + +/** + * What the listing asks when the command line names no price. The symbol code + * and the precision both matter: a quantity written at another precision is + * another price, and nothing downstream catches it. + */ +export const DEFAULT_LISTING_PRICE = '1.00000000 WAX'; + +/** + * The settlement symbol in the precision-and-code notation the action's field + * takes. This starter lists a plain sale, so it names the same symbol as the + * price above. A listing whose two symbols differ is a Delphi sale, which + * settles an oracle conversion of the listing price rather than the price + * itself. + */ +export const SETTLEMENT_SYMBOL = '8,WAX'; + +/** + * The empty string is the contract's seeded default marketplace and is always + * valid. Any other value has to be a marketplace already registered on chain, + * and the chain rejects the transaction when it is not. + */ +export const MAKER_MARKETPLACE = ''; + +/** + * Splits a chain quantity into its amount and its symbol code, or answers null + * when the string is not one. Antelope writes a quantity as an amount at a + * fixed precision, a space, and the code. + * + * @param {string} quantity for example "1.00000000 WAX" + * @returns {{precision: number, code: string}} or null + */ +export function readQuantity(quantity) { + const match = /^(\d+)(?:\.(\d+))?[ ]([A-Z]{1,7})$/.exec(quantity); + + if (match === null) { + return null; + } + + return { precision: match[2] === undefined ? 0 : match[2].length, code: match[3] }; +} + +/** + * Refuses the two listings the composer will happily build and the chain will + * refuse or misprice. + * + * `announceSaleActions` checks nothing about symbols or asset counts, by + * design: both are chain state it is not handed. That leaves the caller to + * hold the line, and these are the two places a starter can hold it without + * reading the chain. + * + * @param {string[]} assetIds assets to list + * @param {string} listingPrice quantity the sale asks + * @param {string} settlementSymbol precision and code the sale settles in + * @returns {void} + */ +export function assertPlainListing(assetIds, listingPrice, settlementSymbol) { + if (assetIds.length !== 1) { + throw new Error( + `a sale lists exactly one asset and this one names ${assetIds.length}: ` + + 'AtomicMarket V2 removed bundle listings, so announce one sale per asset instead', + ); + } + + const price = readQuantity(listingPrice); + + if (price === null) { + throw new Error(`listing_price "${listingPrice}" is not a chain quantity, for example "1.00000000 WAX"`); + } + + if (settlementSymbol !== `${price.precision},${price.code}`) { + throw new Error( + `listing_price "${listingPrice}" and settlement_symbol "${settlementSymbol}" name different symbols: ` + + 'a plain sale settles the price it lists, and a listing whose two symbols differ is a Delphi sale', + ); + } +} + +/** + * The listing pair, in the order the contract needs it. `announcesale` writes + * the row and moves nothing; the AtomicAssets `createoffer` with memo `sale` is + * what activates it. Announcing alone lists nothing and offering alone dangles, + * so the two belong in one transaction, and the composer is what keeps the + * order and the memo literal out of the caller's hands. + * + * @param {string} seller account listing the asset + * @param {string[]} assetIds assets to list, exactly one on V2 + * @param {string} listingPrice quantity the sale asks + * @param {string} settlementSymbol precision and code the sale settles in + * @returns {Array<{account: string, name: string, data: object}>} + */ +export function buildListing( + seller, + assetIds, + listingPrice = DEFAULT_LISTING_PRICE, + settlementSymbol = SETTLEMENT_SYMBOL, +) { + assertPlainListing(assetIds, listingPrice, settlementSymbol); + + return new MarketActionBuilder(ATOMICMARKET).announceSaleActions({ + seller, + asset_ids: assetIds, + listing_price: listingPrice, + settlement_symbol: settlementSymbol, + maker_marketplace: MAKER_MARKETPLACE, + assets_contract: ATOMICASSETS, + }); +} + +/** + * The newest asset this account owns on WAX testnet, or null when it owns + * none. This is how the command finds something to list when the command line + * names no asset. + * + * @param {string} actor owner to read + * @param {object} api explorer client, overridable so a test can point elsewhere + * @returns {Promise} asset id, or null + */ +export async function newestAsset(actor, api = explorerApiForNetwork('wax-testnet')) { + const assets = await api.getAssets({ owner: actor, sort: 'minted', order: 'desc' }, 1, 1); + + return assets.length === 0 ? null : assets[0].asset_id; +} + +/** + * Reads back the sale this run listed. A committed transaction and an indexed + * row are two facts, so this answers null until the indexer has caught up, and + * the caller decides how long to wait. + * + * @param {string} seller account that listed + * @param {string} assetId asset that was listed + * @param {object} api market client, overridable so a test can point elsewhere + * @returns {Promise} the sale row, or null while it is not indexed + */ +export async function readSale(seller, assetId, api = marketApiForNetwork('wax-testnet')) { + const sales = await api.getSales({ seller, asset_id: assetId, state: SaleState.Listed }, 1, 1); + + return sales.length === 0 ? null : sales[0]; +} diff --git a/starters/list-a-sale/src/session.js b/starters/list-a-sale/src/session.js new file mode 100644 index 0000000..6d9dc7b --- /dev/null +++ b/starters/list-a-sale/src/session.js @@ -0,0 +1,31 @@ +/** + * Builds the WharfKit session the action below signs through. The key is read + * from the environment and held in memory by the private-key plugin, which is + * the shape for a script or a continuous-integration job and never for a + * browser. + * + * Each signing starter carries its own copy of this file rather than sharing + * one. A starter is meant to be cloned as a single directory and run, so a + * shared module would be a dependency a reader cannot see. The three copies + * are identical; keep them that way. + */ +import { Chains, Session } from '@wharfkit/session'; +import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey'; + +/** + * WAX testnet is where the V2 contracts run, so it is the chain every starter + * here signs against. The chain id is what a signature commits to: a session + * pointed at the wrong chain produces a transaction the target rejects rather + * than a network error. + * + * @param {Record} env process environment holding the credentials + * @returns {Session} + */ +export function openSession(env) { + return new Session({ + actor: env.WAX_TESTNET_ACTOR, + permission: 'active', + chain: Chains.WAXTestnet, + walletPlugin: new WalletPluginPrivateKey(env.WAX_TESTNET_PRIVATE_KEY), + }); +} diff --git a/starters/list-a-sale/test/list-a-sale.test.js b/starters/list-a-sale/test/list-a-sale.test.js new file mode 100644 index 0000000..1e9bb64 --- /dev/null +++ b/starters/list-a-sale/test/list-a-sale.test.js @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +import { CREDENTIALS } from '../src/credentials.js'; +import { assertPlainListing, buildListing, readQuantity } from '../src/listing.js'; + +const run = promisify(execFile); +const entrypoint = new URL('../src/index.js', import.meta.url).pathname; + +const ASSET = '2199024342156'; + +/** + * Runs the command with the two credentials removed from its environment, + * whatever the ambient environment holds. A run inside the signing arm of + * continuous integration has both variables set, and a skip-path proposition + * that read them would list instead of proving the skip. + * + * @param {Record} overrides variables to add back + * @returns {Promise<{stdout: string, stderr: string}>} + */ +function runWithout(overrides = {}) { + const env = { ...process.env }; + + for (const name of CREDENTIALS) { + delete env[name]; + } + + return run(process.execPath, [entrypoint], { env: { ...env, ...overrides } }); +} + +test('the command exits zero and names both variables when neither is set', async () => { + // execFile rejects on a non-zero exit, so reaching the assertion is the + // exit-zero half of the proposition. + const { stdout } = await runWithout(); + + assert.match(stdout, /WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are not set/); + assert.match(stdout, /signed nothing/); +}); + +test('the command names only the variable that is missing', async () => { + const { stdout } = await runWithout({ WAX_TESTNET_ACTOR: 'mycreator11' }); + + assert.match(stdout, /^WAX_TESTNET_PRIVATE_KEY is not set/); + assert.equal(stdout.includes('WAX_TESTNET_ACTOR is not set'), false); +}); + +test('the listing is announcesale on atomicmarket, then createoffer on atomicassets', () => { + // The order is the contract's, not this starter's: announcing alone lists + // nothing and offering alone dangles. + const actions = buildListing('mycreator11', [ASSET]); + + assert.deepEqual( + actions.map((action) => [action.account, action.name]), + [ + ['atomicmarket', 'announcesale'], + ['atomicassets', 'createoffer'], + ], + ); +}); + +test('the offer carries the memo sale and asks the market contract for nothing back', () => { + const [, createoffer] = buildListing('mycreator11', [ASSET]); + + assert.deepEqual(createoffer.data, { + sender: 'mycreator11', + recipient: 'atomicmarket', + sender_asset_ids: [ASSET], + recipient_asset_ids: [], + memo: 'sale', + }); +}); + +test('the announcement carries the seller, the asset, the price, and the seeded marketplace', () => { + const [announcesale] = buildListing('mycreator11', [ASSET], '12.50000000 WAX'); + + assert.deepEqual(announcesale.data, { + seller: 'mycreator11', + asset_ids: [ASSET], + listing_price: '12.50000000 WAX', + settlement_symbol: '8,WAX', + maker_marketplace: '', + }); +}); + +test('a listing naming more than one asset is refused before an action exists', () => { + // The composer builds a bundle without complaint, because asset counts are + // chain state it is not handed. V2 announcesale then rejects the transaction. + assert.throws( + () => buildListing('mycreator11', [ASSET, '2199024342157']), + /a sale lists exactly one asset and this one names 2/, + ); + assert.throws(() => buildListing('mycreator11', []), /a sale lists exactly one asset and this one names 0/); +}); + +test('a price and a settlement symbol naming different symbols are refused', () => { + assert.throws( + () => assertPlainListing([ASSET], '1.00 USD', '8,WAX'), + /name different symbols/, + ); + assert.throws( + () => assertPlainListing([ASSET], '1.00 WAX', '8,WAX'), + /name different symbols/, + ); +}); + +test('a listing price that is not a chain quantity is refused', () => { + assert.throws(() => assertPlainListing([ASSET], '1 WAX token', '8,WAX'), /is not a chain quantity/); + assert.throws(() => assertPlainListing([ASSET], '1.00000000wax', '8,WAX'), /is not a chain quantity/); +}); + +test('a quantity is read at the precision it is written, zero decimals included', () => { + assert.deepEqual(readQuantity('1.00000000 WAX'), { precision: 8, code: 'WAX' }); + assert.deepEqual(readQuantity('100 KARMA'), { precision: 0, code: 'KARMA' }); + assert.equal(readQuantity('1.0 wax'), null); +}); diff --git a/starters/mint-asset/.gitignore b/starters/mint-asset/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/starters/mint-asset/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/starters/mint-asset/LICENSE b/starters/mint-asset/LICENSE new file mode 100644 index 0000000..c5d175b --- /dev/null +++ b/starters/mint-asset/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) atomicassets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/starters/mint-asset/README.md b/starters/mint-asset/README.md new file mode 100644 index 0000000..59fd97e --- /dev/null +++ b/starters/mint-asset/README.md @@ -0,0 +1,69 @@ +# Mint an asset on WAX testnet + +Signs `createschema` and `mintasset` in one transaction, minting one templateless asset into a collection the signing account already authors, then reads the asset back through the testnet API. Run the `create-collection` starter first if the account authors none. + +## The environment contract + +Two variables, and no other spelling of them: + +| Variable | Holds | +| --- | --- | +| `WAX_TESTNET_ACTOR` | the account that signs, pays the RAM, and receives the asset | +| `WAX_TESTNET_PRIVATE_KEY` | that account's `active` key, in WIF or `PVT_K1_` form | + +When either is absent or blank the starter prints which one it wanted, signs nothing, and exits zero. A clone with no keys therefore runs green and says why, and so does the read-only arm of this repository's own checks. + +``` +$ node src/index.js +WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are not set, so this starter signed nothing. Set both to run it against WAX testnet. +``` + +## Run it + +``` +npm install +WAX_TESTNET_ACTOR=mycreator11 WAX_TESTNET_PRIVATE_KEY=yourkey node src/index.js +WAX_TESTNET_ACTOR=mycreator11 WAX_TESTNET_PRIVATE_KEY=yourkey node src/index.js mycollectn1 +``` + +With no argument the collection is the newest one the API reports for this author. Naming one on the command line skips that read. A run that signs prints what it chose, the transaction the chain accepted, and the row the API serves once the indexer catches up: + +``` +Signing createschema and mintasset for mycollectn1/starterq3m4w as mycreator11@active on WAX testnet. +The chain accepted transaction 4b91...c07d. +The API now serves asset 2199024342156, named Starter asset, owned by mycreator11. +``` + +## What it signs + +`ActionBuilder` from `@atomichub/atomicassets` is synchronous and holds no session. Each method returns one `{ account, name, data }` object, and the command attaches the authorization its session carries, so building actions and signing them stay separate steps. Both actions go in one transaction: a schema with nothing minted against it is a half step nobody wants, and Antelope commits a transaction whole or not at all. + +The schema format carries a line named `name` typed `string`. That is not a field this starter happens to want: `createschema` aborts on any format that omits it. The other two lines, an `image` and a `uint32`, are there to show a non-string type beside it. `createAttributeMap` turns a plain object plus that same per-field type lookup into the attribute map the action carries, so no schema fetch is needed to build one. + +The schema name is fresh per run: a fixed prefix and five characters from `randomBytes`. `createschema` refuses a name the collection already carries, so a fixed name would sign once and fail on every run after it. It also makes the read-back exact, since the collection and schema pair then names one asset. + +`template_id` is `-1`, the contract's "no template" sentinel, which is what lets the asset carry its own immutable data with no `createtempl` step. `tokens_to_back` is empty because native backing is gone in V2 and a non-empty vector aborts the mint. `authorized_minter` is the actor rather than `new_asset_owner`, because the minter pays the RAM for the new row even though the row lives in the owner's scope: a minter without enough RAM staked blocks its own mint however well resourced the recipient is. + +A committed transaction and an indexed row are two facts. The command polls the testnet API for thirty seconds and fails if the row never arrives, rather than reporting a success the reader cannot see. + +## The tests + +``` +npm test +``` + +Eleven propositions run under `node --test`, none of them signing and none needing a key. Two spawn the command with both variables stripped from its environment and assert it exits zero naming the missing one, which is the same path a reader without keys takes. The rest cover the derived schema name's shape, its per-run entropy and its entropy floor, the composed action pair and its order, the mandatory format line, the templateless and unbacked mint, the attribute map, and the two guards that fail before an action exists. + +The two spawning propositions delete the variables from the child's environment rather than reading the ambient one, so they prove the skip path even when a run does hold keys. + +## Residual risk + +The key this starter reads signs on a chain with no value, the account holds no mainnet authority, and the collection is disposable, so the worst case is junk minted into a throwaway collection. Use a testnet account created for this and nothing else, and never a key that also exists on mainnet. + +The key reaches the process through the environment and is held in memory unencrypted by the private-key plugin. Keep it out of the shell history and out of any committed file. In this repository's checks the two values live in a GitHub Actions environment and the signing arm never runs for a pull request, which is the guard that matters: a repository secret with no fork guard is the configuration that leaks, not the chain the key signs on. + +Every run costs the signing account RAM, for the schema once and for each asset after it. Nothing here reclaims it. `burnasset` releases an asset's RAM; a schema is append-only and its row stays. + +## License + +MIT, see LICENSE. diff --git a/starters/mint-asset/package-lock.json b/starters/mint-asset/package-lock.json new file mode 100644 index 0000000..91be5fb --- /dev/null +++ b/starters/mint-asset/package-lock.json @@ -0,0 +1,192 @@ +{ + "name": "mint-asset", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mint-asset", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@atomichub/atomicassets": "2.1.1", + "@wharfkit/session": "1.6.1", + "@wharfkit/wallet-plugin-privatekey": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicassets": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicassets/-/atomicassets-2.1.1.tgz", + "integrity": "sha512-2H+6kNUP1aU6lkqmCln00bsRR+Ej9uPxWL6vEzA47coEI06tMnHuewQM345W+XXgud/igb+M/jxMkDnDbWV0kg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@wharfkit/abicache": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@wharfkit/abicache/-/abicache-1.2.4.tgz", + "integrity": "sha512-DeIPotkMyOXZgLFOmmTXXbynNE1OF2bbEQlaUrqB1kGmNL3WJB1Y09NZ3huvFJylfqD928ZqIdGxB4KJ3iIcGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@wharfkit/antelope": "^1.0.2", + "@wharfkit/signing-request": "^3.1.0", + "pako": "^2.0.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@wharfkit/antelope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@wharfkit/antelope/-/antelope-1.2.0.tgz", + "integrity": "sha512-9q0nvM8yUtjKTQlukKZODAhUN2S2/cfSlIYdh2mPnaOCSH8KOLJ2gYCPuQVvH1FE9AKu312lM5TzhkRccf1VjQ==", + "license": "BSD-3-Clause-No-Military-License", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "elliptic": "^6.5.4", + "hash.js": "^1.0.0", + "pako": "^2.1.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@wharfkit/common": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@wharfkit/common/-/common-1.5.0.tgz", + "integrity": "sha512-eqXkOy+vshcEzK8kED+EsoTPJjlBKHYglgV9CBnZQgIlGrWIRXWH4YaXH3W7EbI/nCRJCaNqxm5fC+pgpFcp8g==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@wharfkit/antelope": "^1.0.0" + } + }, + "node_modules/@wharfkit/session": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@wharfkit/session/-/session-1.6.1.tgz", + "integrity": "sha512-k6ntDGOe8bvD/Ps0erTPTFMdYVFrw5cRvPcEwxytlmRRcNV/M8xWcpCYWdmGDxa8QYqynf/hAkbVh1PSwRGl5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@wharfkit/abicache": "^1.2.1", + "@wharfkit/antelope": "^1.0.11", + "@wharfkit/common": "^1.2.0", + "@wharfkit/signing-request": "^3.1.0", + "pako": "^2.0.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@wharfkit/signing-request": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@wharfkit/signing-request/-/signing-request-3.4.0.tgz", + "integrity": "sha512-WstXfmR9i5pKaYXDUwNFNCgBIvN6u5IRGWSfj5O3XzthbtJUmRoJNtjGMaNnUqZ1MMx5YY4/JpY3b2e6LbpXLw==", + "license": "MIT", + "dependencies": { + "@wharfkit/antelope": "^1.1.1", + "tslib": "^2.0.3" + } + }, + "node_modules/@wharfkit/wallet-plugin-privatekey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wharfkit/wallet-plugin-privatekey/-/wallet-plugin-privatekey-1.1.0.tgz", + "integrity": "sha512-45LPj7AOVDm4RugDEhy0fnQX/BcMffeJPjGUCUrLazJ2S0Sti8nNk4nqiJqyme84c/0gq7d65vvwlmVfGtPVEg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@wharfkit/session": "^1.1.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/starters/mint-asset/package.json b/starters/mint-asset/package.json new file mode 100644 index 0000000..a3a59d3 --- /dev/null +++ b/starters/mint-asset/package.json @@ -0,0 +1,20 @@ +{ + "name": "mint-asset", + "version": "1.0.0", + "private": true, + "description": "Sign createschema and mintasset on WAX testnet for one templateless asset", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "@atomichub/atomicassets": "2.1.1", + "@wharfkit/session": "1.6.1", + "@wharfkit/wallet-plugin-privatekey": "1.1.0" + } +} diff --git a/starters/mint-asset/src/credentials.js b/starters/mint-asset/src/credentials.js new file mode 100644 index 0000000..491a187 --- /dev/null +++ b/starters/mint-asset/src/credentials.js @@ -0,0 +1,42 @@ +/** + * The two variables every signing starter reads, and the message it prints + * when either is absent. + * + * These two spellings are the contract. The starters, their READMEs, and the + * workflow environment that runs them use `WAX_TESTNET_ACTOR` and + * `WAX_TESTNET_PRIVATE_KEY` and no other spelling, so a half-configured + * environment is never read as a configured one. + * + * Each signing starter carries its own copy of this file rather than sharing + * one. A starter is meant to be cloned as a single directory and run, so a + * shared module would be a dependency a reader cannot see. The three copies + * are identical; keep them that way. + */ +export const CREDENTIALS = ['WAX_TESTNET_ACTOR', 'WAX_TESTNET_PRIVATE_KEY']; + +/** + * The credential names that are absent or blank, in the order above. A + * variable set to whitespace counts as absent: an empty secret in a + * continuous-integration environment arrives as an empty string, and treating + * it as a value produces a signing failure that names nothing useful. + * + * @param {Record} env process environment to read + * @returns {string[]} + */ +export function missingCredentials(env) { + return CREDENTIALS.filter((name) => (env[name] ?? '').trim() === ''); +} + +/** + * The line printed on the skip path. It names the variables that are missing + * and says what did not happen, so a reader who cloned without keys gets a + * green run and a legible reason. + * + * @param {string[]} missing names from missingCredentials + * @returns {string} + */ +export function skipMessage(missing) { + const verb = missing.length === 1 ? 'is' : 'are'; + + return `${missing.join(' and ')} ${verb} not set, so this starter signed nothing. Set both to run it against WAX testnet.`; +} diff --git a/starters/mint-asset/src/index.js b/starters/mint-asset/src/index.js new file mode 100644 index 0000000..7da0adc --- /dev/null +++ b/starters/mint-asset/src/index.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Signs `createschema` and `mintasset` in one transaction on WAX testnet, then + * reads the minted asset back through the testnet API. + * + * Usage: WAX_TESTNET_ACTOR=... WAX_TESTNET_PRIVATE_KEY=... node src/index.js [collection] + */ +import { setTimeout as sleep } from 'node:timers/promises'; + +import { missingCredentials, skipMessage } from './credentials.js'; +import { buildMint, deriveSchemaName, newestCollection, readMintedAsset } from './mint.js'; +import { openSession } from './session.js'; + +/** Bounds the wait for the indexer. A commit and an indexed row are two facts. */ +const INDEX_ATTEMPTS = 15; +const INDEX_DELAY_MS = 2000; + +/** The id the node returned, or the one the session resolved before broadcast. */ +function transactionId(result) { + const broadcast = result.response?.transaction_id; + + if (typeof broadcast === 'string') { + return broadcast; + } + + const resolved = result.resolved?.transaction?.id; + + return resolved === undefined ? '(none returned)' : String(resolved); +} + +async function main() { + const missing = missingCredentials(process.env); + + if (missing.length > 0) { + console.log(skipMessage(missing)); + + return; + } + + const actor = process.env.WAX_TESTNET_ACTOR.trim(); + const collectionName = process.argv[2] ?? (await newestCollection(actor)); + + if (collectionName === null) { + throw new Error( + `${actor} authors no collection on WAX testnet. Run the create-collection starter first, ` + + 'or name a collection on the command line.', + ); + } + + const schemaName = deriveSchemaName(); + const actions = buildMint(actor, collectionName, schemaName); + + console.log( + `Signing createschema and mintasset for ${collectionName}/${schemaName} as ${actor}@active on WAX testnet.`, + ); + + const session = openSession(process.env); + const result = await session.transact({ + actions: actions.map((action) => ({ ...action, authorization: [session.permissionLevel] })), + }); + + console.log(`The chain accepted transaction ${transactionId(result)}.`); + + for (let attempt = 1; attempt <= INDEX_ATTEMPTS; attempt += 1) { + const asset = await readMintedAsset(collectionName, schemaName, actor); + + if (asset !== null) { + console.log(`The API now serves asset ${asset.asset_id}, named ${asset.name}, owned by ${asset.owner}.`); + + return; + } + + await sleep(INDEX_DELAY_MS); + } + + throw new Error( + `the chain accepted the transaction and the API had not served an asset in ${collectionName}/${schemaName} ` + + `after ${(INDEX_ATTEMPTS * INDEX_DELAY_MS) / 1000} seconds`, + ); +} + +main().catch((error) => { + console.error(`The mint failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/starters/mint-asset/src/mint.js b/starters/mint-asset/src/mint.js new file mode 100644 index 0000000..a2158dd --- /dev/null +++ b/starters/mint-asset/src/mint.js @@ -0,0 +1,143 @@ +/** + * Derives a fresh schema name, builds the `createschema` and `mintasset` pair + * that mints one templateless asset, and reads the result back. Nothing here + * needs a session, so all of it is callable, and testable, without a key. + */ +import { randomBytes } from 'node:crypto'; + +import { ActionBuilder, createAttributeMap, explorerApiForNetwork } from '@atomichub/atomicassets'; + +/** The AtomicAssets contract account. It carries this name on every chain. */ +export const ATOMICASSETS = 'atomicassets'; + +/** + * The contract's "no template" sentinel. A templateless asset carries its own + * immutable data instead of inheriting a template's, which is what lets this + * starter mint without a `createtempl` step. + */ +export const TEMPLATELESS = -1; + +/** The characters an Antelope name may hold, minus the dot. */ +const NAME_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz12345'; + +/** + * The schema this starter writes. The contract rejects any format that omits a + * line named `name` typed `string`, so that line is not decoration: leave it + * out and `createschema` aborts. The other two lines exist to show a + * non-string type and an image reference alongside it. + */ +export const SCHEMA_FORMAT = [ + { name: 'name', type: 'string' }, + { name: 'img', type: 'image' }, + { name: 'power', type: 'uint32' }, +]; + +/** + * The same field types again, in the per-key shape `createAttributeMap` reads. + * The format above is what the chain stores; this is what turns a plain object + * into the attribute map an action carries, and no schema fetch is needed for + * it. + */ +export const SCHEMA_TYPES = { name: 'string', img: 'image', power: 'uint32' }; + +/** What the minted asset holds when the command line names nothing else. */ +export const DEFAULT_ATTRIBUTES = { + name: 'Starter asset', + img: 'QmYm1FG7LxhF3mFUaVmVEVqRztEmByVbHwL6ZWXwVY2dvb', + power: 1, +}; + +/** + * Builds a twelve-character schema name: a fixed prefix a reader recognises, + * and five characters from entropy so every run writes its own schema. + * + * A fresh schema per run is what makes this starter repeatable. `createschema` + * refuses a name the collection already carries, so a fixed name would sign + * once and fail on every run after it. + * + * @param {Uint8Array} entropy at least five bytes + * @returns {string} + */ +export function deriveSchemaName(entropy = randomBytes(5)) { + if (entropy.length < 5) { + throw new Error(`entropy holds ${entropy.length} bytes, and a derived schema name needs five`); + } + + // The modulo bias across 31 characters is irrelevant here: the tail exists so + // two runs of a throwaway starter do not collide, not to be unguessable. + const tail = [...entropy] + .slice(0, 5) + .map((byte) => NAME_CHARACTERS[byte % NAME_CHARACTERS.length]) + .join(''); + + return `starter${tail}`; +} + +/** + * The two actions, in the order the contract needs them. They go in one + * transaction: a schema that exists with nothing minted against it is a half + * step nobody wants, and Antelope commits a transaction whole or not at all. + * + * `authorized_minter` is the actor rather than `new_asset_owner`, because the + * minter pays the RAM for the new row even though the row lives in the owner's + * scope. `tokens_to_back` is empty: native backing is gone in V2, and a + * non-empty vector aborts the mint. + * + * @param {string} actor collection author, minter, and recipient + * @param {string} collectionName collection to mint into + * @param {string} schemaName name from deriveSchemaName + * @param {object} attributes values matching SCHEMA_TYPES + * @param {number} templateId template to mint against, or TEMPLATELESS + * @returns {Array<{account: string, name: string, data: object}>} + */ +export function buildMint( + actor, + collectionName, + schemaName, + attributes = DEFAULT_ATTRIBUTES, + templateId = TEMPLATELESS, +) { + const builder = new ActionBuilder(ATOMICASSETS); + const immutableData = createAttributeMap(attributes, SCHEMA_TYPES); + + return [ + builder.createschema(actor, collectionName, schemaName, SCHEMA_FORMAT), + builder.mintasset(actor, collectionName, schemaName, templateId, actor, immutableData, [], []), + ]; +} + +/** + * The newest collection the testnet API reports for this author, or null when + * the account authors none. This is how the command finds somewhere to mint + * when the command line names no collection. + * + * @param {string} actor author to read + * @param {object} api explorer client, overridable so a test can point elsewhere + * @returns {Promise} collection name, or null + */ +export async function newestCollection(actor, api = explorerApiForNetwork('wax-testnet')) { + const collections = await api.getCollections({ author: actor, sort: 'created', order: 'desc' }, 1, 1); + + return collections.length === 0 ? null : collections[0].collection_name; +} + +/** + * Reads back the asset this run minted. The schema name is fresh per run, so + * the collection and schema pair names exactly one asset and no earlier one + * can be mistaken for it. + * + * A committed transaction and an indexed row are two facts, so this answers + * null until the indexer has caught up, and the caller decides how long to + * wait. + * + * @param {string} collectionName collection minted into + * @param {string} schemaName schema minted against + * @param {string} owner account the asset was minted to + * @param {object} api explorer client, overridable so a test can point elsewhere + * @returns {Promise} the asset row, or null while it is not indexed + */ +export async function readMintedAsset(collectionName, schemaName, owner, api = explorerApiForNetwork('wax-testnet')) { + const assets = await api.getAssets({ owner, collection_name: collectionName, schema_name: schemaName }, 1, 1); + + return assets.length === 0 ? null : assets[0]; +} diff --git a/starters/mint-asset/src/session.js b/starters/mint-asset/src/session.js new file mode 100644 index 0000000..6d9dc7b --- /dev/null +++ b/starters/mint-asset/src/session.js @@ -0,0 +1,31 @@ +/** + * Builds the WharfKit session the action below signs through. The key is read + * from the environment and held in memory by the private-key plugin, which is + * the shape for a script or a continuous-integration job and never for a + * browser. + * + * Each signing starter carries its own copy of this file rather than sharing + * one. A starter is meant to be cloned as a single directory and run, so a + * shared module would be a dependency a reader cannot see. The three copies + * are identical; keep them that way. + */ +import { Chains, Session } from '@wharfkit/session'; +import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey'; + +/** + * WAX testnet is where the V2 contracts run, so it is the chain every starter + * here signs against. The chain id is what a signature commits to: a session + * pointed at the wrong chain produces a transaction the target rejects rather + * than a network error. + * + * @param {Record} env process environment holding the credentials + * @returns {Session} + */ +export function openSession(env) { + return new Session({ + actor: env.WAX_TESTNET_ACTOR, + permission: 'active', + chain: Chains.WAXTestnet, + walletPlugin: new WalletPluginPrivateKey(env.WAX_TESTNET_PRIVATE_KEY), + }); +} diff --git a/starters/mint-asset/test/mint-asset.test.js b/starters/mint-asset/test/mint-asset.test.js new file mode 100644 index 0000000..7a93f48 --- /dev/null +++ b/starters/mint-asset/test/mint-asset.test.js @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +import { CREDENTIALS } from '../src/credentials.js'; +import { buildMint, deriveSchemaName, SCHEMA_FORMAT, TEMPLATELESS } from '../src/mint.js'; + +const run = promisify(execFile); +const entrypoint = new URL('../src/index.js', import.meta.url).pathname; + +/** + * Runs the command with the two credentials removed from its environment, + * whatever the ambient environment holds. A run inside the signing arm of + * continuous integration has both variables set, and a skip-path proposition + * that read them would mint instead of proving the skip. + * + * @param {Record} overrides variables to add back + * @returns {Promise<{stdout: string, stderr: string}>} + */ +function runWithout(overrides = {}) { + const env = { ...process.env }; + + for (const name of CREDENTIALS) { + delete env[name]; + } + + return run(process.execPath, [entrypoint], { env: { ...env, ...overrides } }); +} + +test('the command exits zero and names both variables when neither is set', async () => { + // execFile rejects on a non-zero exit, so reaching the assertion is the + // exit-zero half of the proposition. + const { stdout } = await runWithout(); + + assert.match(stdout, /WAX_TESTNET_ACTOR and WAX_TESTNET_PRIVATE_KEY are not set/); + assert.match(stdout, /signed nothing/); +}); + +test('the command names only the variable that is missing', async () => { + const { stdout } = await runWithout({ WAX_TESTNET_PRIVATE_KEY: 'PVT_K1_placeholder' }); + + assert.match(stdout, /^WAX_TESTNET_ACTOR is not set/); + assert.equal(stdout.includes('WAX_TESTNET_PRIVATE_KEY is not set'), false); +}); + +test('a derived schema name is twelve characters of the name alphabet', () => { + const name = deriveSchemaName(); + + assert.equal(name.length, 12); + assert.match(name, /^starter[a-z1-5]{5}$/); +}); + +test('two derivations differ, so a second run writes its own schema', () => { + assert.notEqual(deriveSchemaName(), deriveSchemaName()); +}); + +test('a derivation refuses entropy too short to fill the tail', () => { + assert.throws( + () => deriveSchemaName(Uint8Array.from([1, 2])), + /entropy holds 2 bytes, and a derived schema name needs five/, + ); +}); + +test('the mint is createschema then mintasset, both on atomicassets', () => { + const actions = buildMint('mycreator11', 'mycollectn1', 'starteraaaaa'); + + assert.deepEqual( + actions.map((action) => [action.account, action.name]), + [ + ['atomicassets', 'createschema'], + ['atomicassets', 'mintasset'], + ], + ); +}); + +test('the schema format carries the name and string line the contract requires', () => { + // createschema aborts on a format that omits it, so this line is a contract + // requirement rather than a field this starter happens to want. + assert.deepEqual(SCHEMA_FORMAT[0], { name: 'name', type: 'string' }); + + const [createschema] = buildMint('mycreator11', 'mycollectn1', 'starteraaaaa'); + + assert.deepEqual(createschema.data, { + authorized_creator: 'mycreator11', + collection_name: 'mycollectn1', + schema_name: 'starteraaaaa', + schema_format: SCHEMA_FORMAT, + }); +}); + +test('the mint is templateless, backs no tokens, and bills the minter', () => { + const [, mintasset] = buildMint('mycreator11', 'mycollectn1', 'starteraaaaa'); + + assert.equal(mintasset.data.template_id, TEMPLATELESS); + assert.deepEqual(mintasset.data.tokens_to_back, []); + assert.equal(mintasset.data.authorized_minter, 'mycreator11'); + assert.equal(mintasset.data.new_asset_owner, 'mycreator11'); +}); + +test('the immutable data is the attribute map the contract stores', () => { + const [, mintasset] = buildMint('mycreator11', 'mycollectn1', 'starteraaaaa', { + name: 'Starter asset', + img: 'QmYm1FG7LxhF3mFUaVmVEVqRztEmByVbHwL6ZWXwVY2dvb', + power: 7, + }); + + assert.deepEqual(mintasset.data.immutable_data, [ + { key: 'name', value: ['string', 'Starter asset'] }, + { key: 'img', value: ['string', 'QmYm1FG7LxhF3mFUaVmVEVqRztEmByVbHwL6ZWXwVY2dvb'] }, + { key: 'power', value: ['uint32', 7] }, + ]); + assert.deepEqual(mintasset.data.mutable_data, []); +}); + +test('an attribute the schema does not type fails before an action exists', () => { + assert.throws( + () => buildMint('mycreator11', 'mycollectn1', 'starteraaaaa', { rarity: 'Abundant' }), + /no type given for field 'rarity'/, + ); +}); + +test('a template id that is not an int32 fails before an action exists', () => { + // A string-to-number conversion that produced a NaN would otherwise reach the + // signer as null, because JSON has no form for it. + assert.throws( + () => buildMint('mycreator11', 'mycollectn1', 'starteraaaaa', undefined, Number.NaN), + /template_id NaN is not an int32/, + ); +}); diff --git a/starters/read-assets/.gitignore b/starters/read-assets/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/starters/read-assets/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/starters/read-assets/LICENSE b/starters/read-assets/LICENSE new file mode 100644 index 0000000..c5d175b --- /dev/null +++ b/starters/read-assets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) atomicassets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/starters/read-assets/README.md b/starters/read-assets/README.md new file mode 100644 index 0000000..f09bc6b --- /dev/null +++ b/starters/read-assets/README.md @@ -0,0 +1,42 @@ +# Read the assets an account holds + +Prints the asset id, the name, the collection, and the first attribute of the assets one WAX mainnet account holds. It signs nothing and needs no key, no account, and no registration: the hosted API answers an anonymous request. + +## Run it + +``` +npm install +node src/index.js +node src/index.js someaccount +``` + +With no argument the read is against `federation`, the account that authors the alien.worlds collection. Output is one line per asset: + +``` +5 of the assets federation holds on WAX mainnet: + 1099925383114 Standard Shovel alien.worlds img=QmYm1FG7Lx... +``` + +## What it reads + +`explorerApiForNetwork('wax')` from `@atomichub/atomicassets` builds a client against `https://wax.api.atomicassets.io`, and `getAssets({ owner })` reads the first page of that account's holdings. The `data` field on each row is the merged view of template and asset data, so the first entry is the first attribute the asset actually resolves to. + +Every value printed passes through `printable()` first. Asset data is written by whoever minted the asset, and a terminal acts on what it is handed, so an escape sequence in a name would otherwise rewrite the rows around it. + +A failed read exits non-zero and names the account. An empty list means the account holds nothing, which is a different answer from a failure and is printed as one. + +## The tests + +``` +npm test +``` + +Five propositions run under `node --test`. One reads the live endpoint and asserts the shape of what comes back; it skips itself when the host refuses a connection, so a run without a network reports a skip rather than a failure. An HTTP error is not a skip: the API answering with an error is the drift these starters exist to catch. The other four run offline against a row captured from the API, covering the attribute pick, an asset that resolves no attributes, the control-character guard, and the column cut. + +## Residual risk + +This starter signs nothing and holds no key, so there is no credential to leak. What remains is that it prints data it did not write: asset names and attribute values come from whoever minted the asset, and they reach a terminal that acts on control characters. `printable()` is the bound on that, and it is a rendering guard rather than a validator, so treat the values as untrusted anywhere else you take them. It also reads a public endpoint over the network, which can be slow, rate limited, or down, and a failed read is reported rather than retried. + +## License + +MIT, see LICENSE. diff --git a/starters/read-assets/package-lock.json b/starters/read-assets/package-lock.json new file mode 100644 index 0000000..8fbe38a --- /dev/null +++ b/starters/read-assets/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "read-assets", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "read-assets", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@atomichub/atomicassets": "2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicassets": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicassets/-/atomicassets-2.1.1.tgz", + "integrity": "sha512-2H+6kNUP1aU6lkqmCln00bsRR+Ej9uPxWL6vEzA47coEI06tMnHuewQM345W+XXgud/igb+M/jxMkDnDbWV0kg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/starters/read-assets/package.json b/starters/read-assets/package.json new file mode 100644 index 0000000..135875d --- /dev/null +++ b/starters/read-assets/package.json @@ -0,0 +1,18 @@ +{ + "name": "read-assets", + "version": "1.0.0", + "private": true, + "description": "Print the assets a WAX mainnet account holds, read through the AtomicAssets explorer API", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "@atomichub/atomicassets": "2.1.1" + } +} diff --git a/starters/read-assets/src/assets.js b/starters/read-assets/src/assets.js new file mode 100644 index 0000000..c5ba094 --- /dev/null +++ b/starters/read-assets/src/assets.js @@ -0,0 +1,103 @@ +/** + * Reads one account's AtomicAssets holdings from the hosted explorer API and + * shapes each row for printing. The read and the shaping live here rather than + * in the command so a test can drive the same functions the command runs. + */ +import { NETWORK_ENDPOINTS, explorerApiForNetwork } from '@atomichub/atomicassets'; + +/** + * The account read when the command line names none. It authors the + * alien.worlds collection and holds assets from several collections, so the + * first page is never empty. + */ +export const DEFAULT_ACCOUNT = 'federation'; + +/** The host the WAX mainnet factory points at, exported so a test can probe it. */ +export const API_HOST = NETWORK_ENDPOINTS.wax.api; + +/** + * @typedef {object} AssetSummary + * @property {string} assetId + * @property {string} name asset name, or null when the asset carries none + * @property {string} collection + * @property {{name: string, value: unknown}} attribute first attribute, or null + */ + +/** + * Renders a value for a terminal. Asset data is written by whoever minted the + * asset, so a name or an attribute value can carry a control character, and a + * terminal acts on what it is handed: an escape sequence moves the cursor, + * recolours the line, or hides the text after it. Every value printed below + * passes through here, so one row cannot rewrite the rows around it. + * + * @param {unknown} value + * @param {number} limit longest rendered string, in code points + * @returns {string} + */ +export function printable(value, limit = 48) { + const text = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value)); + const flat = [...text] + .map((character) => { + const code = character.codePointAt(0); + + return code < 0x20 || code === 0x7f ? ' ' : character; + }) + .join(''); + + return [...flat].length > limit ? `${[...flat].slice(0, limit - 3).join('')}...` : flat; +} + +/** + * Picks the fields a reader wants off an API asset row. `data` is the merged + * view the API builds from template and asset data, so the first entry is the + * first attribute the asset actually resolves to. + * + * @param {object} asset one row from getAssets + * @returns {AssetSummary} + */ +export function summarizeAsset(asset) { + const entries = Object.entries(asset.data ?? {}); + const first = entries.length === 0 ? null : entries[0]; + + return { + assetId: asset.asset_id, + name: asset.name ?? null, + collection: asset.collection.collection_name, + attribute: first === null ? null : { name: first[0], value: first[1] }, + }; +} + +/** + * One printable line per asset. + * + * @param {AssetSummary} summary + * @returns {string} + */ +export function formatRow(summary) { + const attribute = + summary.attribute === null + ? 'no attributes' + : `${printable(summary.attribute.name)}=${printable(summary.attribute.value)}`; + + return [ + summary.assetId.padStart(14), + printable(summary.name ?? '(no name)').padEnd(32), + printable(summary.collection).padEnd(14), + attribute, + ].join(' '); +} + +/** + * Reads a page of the assets an account holds on WAX mainnet. No key, no + * account, and no registration: the hosted API answers an anonymous request. + * + * @param {string} account account name to read + * @param {number} limit rows to ask for + * @param {object} api explorer client, overridable so a test can point elsewhere + * @returns {Promise} + */ +export async function readAssets(account, limit = 5, api = explorerApiForNetwork('wax')) { + const assets = await api.getAssets({ owner: account }, 1, limit); + + return assets.map(summarizeAsset); +} diff --git a/starters/read-assets/src/index.js b/starters/read-assets/src/index.js new file mode 100644 index 0000000..3a4cac8 --- /dev/null +++ b/starters/read-assets/src/index.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Prints the assets one WAX mainnet account holds: asset id, name, collection, + * and the first attribute the asset resolves to. + * + * Usage: node src/index.js [account] + */ +import { DEFAULT_ACCOUNT, formatRow, readAssets } from './assets.js'; + +const account = process.argv[2] ?? DEFAULT_ACCOUNT; + +async function main() { + const rows = await readAssets(account); + + if (rows.length === 0) { + console.log(`${account} holds no assets.`); + + return; + } + + console.log(`${rows.length} of the assets ${account} holds on WAX mainnet:`); + + for (const row of rows) { + console.log(formatRow(row)); + } +} + +main().catch((error) => { + // The read is the whole starter, so a failure exits non-zero rather than + // printing an empty list that reads like an account holding nothing. + console.error(`Reading the assets of ${account} failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/starters/read-assets/test/asset-row.fixture.json b/starters/read-assets/test/asset-row.fixture.json new file mode 100644 index 0000000..5f856ca --- /dev/null +++ b/starters/read-assets/test/asset-row.fixture.json @@ -0,0 +1,31 @@ +{ + "contract": "atomicassets", + "asset_id": "1099925383114", + "owner": "federation", + "name": "Standard Shovel", + "is_transferable": true, + "is_burnable": true, + "template_mint": "9850900", + "collection": { + "collection_name": "alien.worlds", + "name": "Alien Worlds", + "author": "federation", + "market_fee": 0.01 + }, + "schema": { + "schema_name": "tool.worlds" + }, + "data": { + "img": "QmYm1FG7LxhF3mFUaVmVEVqRztEmByVbHwL6ZWXwVY2dvb", + "ease": 10, + "luck": 5, + "name": "Standard Shovel", + "type": "Extractor", + "delay": 80, + "shine": "Stone", + "cardid": 1, + "rarity": "Abundant", + "backimg": "QmaUNXHeeFvMGD4vPCC3vpGTr77tJvBHjh1ndUm4J7o4tP", + "difficulty": 0 + } +} diff --git a/starters/read-assets/test/read-assets.test.js b/starters/read-assets/test/read-assets.test.js new file mode 100644 index 0000000..5520c2a --- /dev/null +++ b/starters/read-assets/test/read-assets.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { API_HOST, DEFAULT_ACCOUNT, formatRow, printable, readAssets, summarizeAsset } from '../src/assets.js'; + +// Captured from GET https://wax.api.atomicassets.io/atomicassets/v1/assets/1099925383114 +// and trimmed to the fields these propositions read. The shape belongs to the +// API, so it is captured from the API rather than written to match the code. +const row = JSON.parse(await readFile(new URL('./asset-row.fixture.json', import.meta.url), 'utf8')); + +/** + * Answers whether the API host accepts a connection. Only a connect-class + * failure counts as offline: an HTTP error is the API answering, and a test + * that skipped on that would hide the drift these starters run to catch. The + * SDK replaces the cause of a failed fetch with a 500, so the probe is a bare + * fetch rather than an SDK call. + */ +async function online() { + try { + await fetch(`${API_HOST}/health`); + + return true; + } catch { + return false; + } +} + +test('a live read returns rows carrying an asset id, a collection, and an attribute', async (t) => { + if (!(await online())) { + t.skip(`${API_HOST} refused a connection, so this proposition needs a network`); + + return; + } + + const rows = await readAssets(DEFAULT_ACCOUNT, 3); + + assert.ok(rows.length > 0, `${DEFAULT_ACCOUNT} holds assets, so the first page is never empty`); + + for (const summary of rows) { + assert.match(summary.assetId, /^\d+$/); + assert.match(summary.collection, /^[a-z1-5.]{1,12}$/); + assert.ok(summary.attribute === null || typeof summary.attribute.name === 'string'); + } +}); + +test('a summary carries the asset id, the name, the collection, and the first attribute', () => { + assert.deepEqual(summarizeAsset(row), { + assetId: '1099925383114', + name: 'Standard Shovel', + collection: 'alien.worlds', + attribute: { name: 'img', value: 'QmYm1FG7LxhF3mFUaVmVEVqRztEmByVbHwL6ZWXwVY2dvb' }, + }); +}); + +test('an asset that resolves no attributes summarizes to a null attribute', () => { + const summary = summarizeAsset({ ...row, data: {} }); + + assert.equal(summary.attribute, null); + assert.equal(formatRow(summary).endsWith('no attributes'), true); +}); + +test('a control character in asset data cannot rewrite the printed row', () => { + const summary = summarizeAsset({ ...row, name: 'Shovel\u001b[2Krewritten\nsecond line' }); + const line = formatRow(summary); + + assert.equal(line.includes('\u001b'), false); + assert.equal(line.includes('\n'), false); + assert.equal(printable('abc'), 'abc'); +}); + +test('a printed value is cut to the column width', () => { + assert.equal(printable('x'.repeat(60), 10), `${'x'.repeat(7)}...`); +}); diff --git a/starters/storefront-read/.gitignore b/starters/storefront-read/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/starters/storefront-read/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/starters/storefront-read/LICENSE b/starters/storefront-read/LICENSE new file mode 100644 index 0000000..c5d175b --- /dev/null +++ b/starters/storefront-read/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) atomicassets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/starters/storefront-read/README.md b/starters/storefront-read/README.md new file mode 100644 index 0000000..5fedad8 --- /dev/null +++ b/starters/storefront-read/README.md @@ -0,0 +1,42 @@ +# Read a collection's live sales + +Prints the sale id, the price, the seller, and the listed asset for the sales one collection has open on WAX mainnet. It signs nothing and needs no key, no account, and no registration: the hosted API answers an anonymous request. + +## Run it + +``` +npm install +node src/index.js +node src/index.js somecollectn +``` + +With no argument the read is against alien.worlds, which lists continuously. Output is one line per sale: + +``` +5 of the sales listed for alien.worlds on WAX mainnet: + 173903111 78.00000000 WAX sj.3a.c.wam 1099512475721 Kite Axe +``` + +## What it reads + +`marketApiForNetwork('wax')` from `@atomichub/atomicmarket` builds a client against `https://wax.api.atomicassets.io`, and `getSales` reads the collection's rows newest first. The state filter is `SaleState.Listed`, which leaves out the sold, cancelled, and invalid rows a storefront must not offer. + +`price.amount` is an integer in the token's smallest unit, and `price.token_precision` says where the point goes. The SDK's own `formatQuantity` renders the pair, because a price rendered at a precision nobody chose is a wrong price with nothing downstream to catch it. AtomicMarket v2 lists one asset per sale; a row carrying several is a legacy v1 bundle, and this starter names the first of them. + +A failed read exits non-zero and names the collection. An empty list means nothing is listed, which is a different answer from a failure and is printed as one. + +## The tests + +``` +npm test +``` + +Five propositions run under `node --test`. One reads the live endpoint and asserts the shape of what comes back; it skips itself when the host refuses a connection, so a run without a network reports a skip rather than a failure. An HTTP error is not a skip: the API answering with an error is the drift these starters exist to catch. The other four run offline against a row captured from the API, covering the rendered price, a price below one whole token, a legacy bundle, and the control-character guard. + +## Residual risk + +This starter signs nothing and holds no key, so there is no credential to leak. What remains is that it prints data it did not write: seller names, asset names, and collection names come from the chain, and they reach a terminal that acts on control characters. `printable()` is the bound on that, and it is a rendering guard rather than a validator, so treat the values as untrusted anywhere else you take them. The prices are another such value: a storefront that renders one at a precision it chose itself shows a wrong price, which is why `formatQuantity` renders the pair the API serves. The starter also reads a public endpoint over the network, which can be slow, rate limited, or down, and a failed read is reported rather than retried. + +## License + +MIT, see LICENSE. diff --git a/starters/storefront-read/package-lock.json b/starters/storefront-read/package-lock.json new file mode 100644 index 0000000..e3b04b1 --- /dev/null +++ b/starters/storefront-read/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "storefront-read", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "storefront-read", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@atomichub/atomicmarket": "2.4.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicassets": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicassets/-/atomicassets-2.1.1.tgz", + "integrity": "sha512-2H+6kNUP1aU6lkqmCln00bsRR+Ej9uPxWL6vEzA47coEI06tMnHuewQM345W+XXgud/igb+M/jxMkDnDbWV0kg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@atomichub/atomicmarket": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@atomichub/atomicmarket/-/atomicmarket-2.4.1.tgz", + "integrity": "sha512-RnDfu4AmGMzQdOP/CoXcpX56vjNoZEbjbIICjabKloZH6o6xLLjwudk6VL9RleeHiY0i4TYeIJx8HVRriG16GA==", + "license": "MIT", + "dependencies": { + "@atomichub/atomicassets": "^2.0.0" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/starters/storefront-read/package.json b/starters/storefront-read/package.json new file mode 100644 index 0000000..92e9080 --- /dev/null +++ b/starters/storefront-read/package.json @@ -0,0 +1,18 @@ +{ + "name": "storefront-read", + "version": "1.0.0", + "private": true, + "description": "Print the live sales of one collection, read through the AtomicMarket API", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "@atomichub/atomicmarket": "2.4.1" + } +} diff --git a/starters/storefront-read/src/index.js b/starters/storefront-read/src/index.js new file mode 100644 index 0000000..818b49b --- /dev/null +++ b/starters/storefront-read/src/index.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Prints the live sales of one collection on WAX mainnet: sale id, price, + * seller, and the listed asset. + * + * Usage: node src/index.js [collection] + */ +import { DEFAULT_COLLECTION, formatRow, readSales } from './sales.js'; + +const collection = process.argv[2] ?? DEFAULT_COLLECTION; + +async function main() { + const rows = await readSales(collection); + + if (rows.length === 0) { + console.log(`${collection} has no listed sales.`); + + return; + } + + console.log(`${rows.length} of the sales listed for ${collection} on WAX mainnet:`); + + for (const row of rows) { + console.log(formatRow(row)); + } +} + +main().catch((error) => { + // The read is the whole starter, so a failure exits non-zero rather than + // printing an empty list that reads like a collection nobody is selling. + console.error(`Reading the sales of ${collection} failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/starters/storefront-read/src/sales.js b/starters/storefront-read/src/sales.js new file mode 100644 index 0000000..bc8846c --- /dev/null +++ b/starters/storefront-read/src/sales.js @@ -0,0 +1,106 @@ +/** + * Reads one collection's live sales from the hosted AtomicMarket API and + * shapes each row for printing. The read and the shaping live here rather than + * in the command so a test can drive the same functions the command runs. + */ +import { NETWORK_ENDPOINTS, SaleState, formatQuantity, marketApiForNetwork } from '@atomichub/atomicmarket'; + +/** The collection read when the command line names none. It lists continuously. */ +export const DEFAULT_COLLECTION = 'alien.worlds'; + +/** The host the WAX mainnet factory points at, exported so a test can probe it. */ +export const API_HOST = NETWORK_ENDPOINTS.wax.api; + +/** + * @typedef {object} SaleSummary + * @property {string} saleId + * @property {string} price rendered quantity, for example "82.99554999 WAX" + * @property {string} seller + * @property {string} assetId the listed asset, or null when the row lists none + * @property {string} assetName + */ + +/** + * Renders a value for a terminal. Asset and collection data is written by + * whoever minted the asset, so a name can carry a control character, and a + * terminal acts on what it is handed: an escape sequence moves the cursor, + * recolours the line, or hides the text after it. + * + * @param {unknown} value + * @param {number} limit longest rendered string, in code points + * @returns {string} + */ +export function printable(value, limit = 32) { + const text = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value)); + const flat = [...text] + .map((character) => { + const code = character.codePointAt(0); + + return code < 0x20 || code === 0x7f ? ' ' : character; + }) + .join(''); + + return [...flat].length > limit ? `${[...flat].slice(0, limit - 3).join('')}...` : flat; +} + +/** + * Picks the fields a storefront shows off an API sale row. `price.amount` is + * an integer in the token's smallest unit, so the SDK's own `formatQuantity` + * renders it rather than a division here: the precision belongs to the token, + * and a price rendered at the wrong one is a wrong price nothing downstream + * can catch. + * + * AtomicMarket v2 lists one asset per sale. A row carrying several is a legacy + * bundle from v1, and this summary names the first of them. + * + * @param {object} sale one row from getSales + * @returns {SaleSummary} + */ +export function summarizeSale(sale) { + const asset = sale.assets.length === 0 ? null : sale.assets[0]; + + return { + saleId: sale.sale_id, + price: formatQuantity(BigInt(sale.price.amount), sale.price.token_precision, sale.price.token_symbol), + seller: sale.seller, + assetId: asset === null ? null : asset.asset_id, + assetName: asset === null ? null : (asset.name ?? null), + }; +} + +/** + * One printable line per sale. + * + * @param {SaleSummary} summary + * @returns {string} + */ +export function formatRow(summary) { + return [ + summary.saleId.padStart(10), + summary.price.padStart(22), + printable(summary.seller).padEnd(13), + (summary.assetId ?? '(no asset)').padStart(14), + printable(summary.assetName ?? '(no name)'), + ].join(' '); +} + +/** + * Reads the listed sales of one collection on WAX mainnet, newest first. No + * key, no account, and no registration: the hosted API answers an anonymous + * request. `SaleState.Listed` is the state filter that leaves out the sold, + * cancelled, and invalid rows a storefront must not offer. + * + * @param {string} collection collection name to read + * @param {number} limit rows to ask for + * @param {object} api market client, overridable so a test can point elsewhere + * @returns {Promise} + */ +export async function readSales(collection, limit = 5, api = marketApiForNetwork('wax')) { + const sales = await api.getSales( + { collection_name: collection, state: SaleState.Listed, sort: 'created', order: 'desc' }, + 1, + limit, + ); + + return sales.map(summarizeSale); +} diff --git a/starters/storefront-read/test/sale-row.fixture.json b/starters/storefront-read/test/sale-row.fixture.json new file mode 100644 index 0000000..5176fe6 --- /dev/null +++ b/starters/storefront-read/test/sale-row.fixture.json @@ -0,0 +1,38 @@ +{ + "market_contract": "atomicmarket", + "assets_contract": "atomicassets", + "sale_id": "173901315", + "seller": "luckynfts.gm", + "buyer": null, + "offer_id": "182777767", + "price": { + "token_contract": "eosio.token", + "token_symbol": "WAX", + "token_precision": 8, + "median": null, + "amount": "8299554999" + }, + "listing_symbol": "WAX", + "listing_price": "8299554999", + "assets": [ + { + "contract": "atomicassets", + "asset_id": "1099515375551", + "owner": "luckynfts.gm", + "name": "Widow Maker", + "collection": { + "collection_name": "alien.worlds" + }, + "schema": { + "schema_name": "arms.worlds" + } + } + ], + "maker_marketplace": "market.wax", + "taker_marketplace": null, + "collection": { + "collection_name": "alien.worlds", + "market_fee": 0.01 + }, + "state": 1 +} diff --git a/starters/storefront-read/test/storefront-read.test.js b/starters/storefront-read/test/storefront-read.test.js new file mode 100644 index 0000000..32105ac --- /dev/null +++ b/starters/storefront-read/test/storefront-read.test.js @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { API_HOST, DEFAULT_COLLECTION, formatRow, readSales, summarizeSale } from '../src/sales.js'; + +// Captured from GET /atomicmarket/v1/sales?collection_name=alien.worlds&state=1 +// on https://wax.api.atomicassets.io, the route getSales calls, and trimmed to +// the fields these propositions read. The shape and the integer amount belong +// to the API, so they are captured from the API rather than written to match +// the code. +const row = JSON.parse(await readFile(new URL('./sale-row.fixture.json', import.meta.url), 'utf8')); + +/** + * Answers whether the API host accepts a connection. Only a connect-class + * failure counts as offline: an HTTP error is the API answering, and a test + * that skipped on that would hide the drift these starters run to catch. The + * SDK replaces the cause of a failed fetch with a 500, so the probe is a bare + * fetch rather than an SDK call. + */ +async function online() { + try { + await fetch(`${API_HOST}/health`); + + return true; + } catch { + return false; + } +} + +test('a live read returns listed sales carrying a price, a seller, and an asset', async (t) => { + if (!(await online())) { + t.skip(`${API_HOST} refused a connection, so this proposition needs a network`); + + return; + } + + const rows = await readSales(DEFAULT_COLLECTION, 3); + + assert.ok(rows.length > 0, `${DEFAULT_COLLECTION} lists continuously, so the first page is never empty`); + + for (const summary of rows) { + assert.match(summary.saleId, /^\d+$/); + assert.match(summary.price, /^\d+(\.\d+)? [A-Z]{1,7}$/); + assert.match(summary.seller, /^[a-z1-5.]{1,12}$/); + assert.match(summary.assetId, /^\d+$/); + } +}); + +test('a summary renders the price at the token precision and names the seller and the asset', () => { + assert.deepEqual(summarizeSale(row), { + saleId: '173901315', + price: '82.99554999 WAX', + seller: 'luckynfts.gm', + assetId: '1099515375551', + assetName: 'Widow Maker', + }); +}); + +test('a price below one whole token keeps its leading zeros', () => { + const summary = summarizeSale({ ...row, price: { ...row.price, amount: '5' } }); + + assert.equal(summary.price, '0.00000005 WAX'); +}); + +test('a legacy bundle row names its first asset', () => { + const second = { ...row.assets[0], asset_id: '1099515375552', name: 'Second Asset' }; + const summary = summarizeSale({ ...row, assets: [...row.assets, second] }); + + assert.equal(summary.assetId, '1099515375551'); +}); + +test('a control character in a seller name cannot rewrite the printed row', () => { + const line = formatRow(summarizeSale({ ...row, seller: 'seller\u001b[2K.wam' })); + + assert.equal(line.includes('\u001b'), false); +}); From 85efe508e794aa8b5bf4670a695c32441ecc637e Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 13:42:07 -0400 Subject: [PATCH 2/6] ci: run the starters, so a broken one fails before a reader clones it The starters job was a stub that skipped itself while the directory did not exist. It now runs, in two arms split by what they hold rather than by what they do. The first arm runs every starter's tests on every event, with no key in scope at all. That is what lets a fork pull request exercise the live reads and the skip paths without the workflow handing it anything sensitive. The second arm executes the three signing starters against WAX testnet, because a starter proved only by its own tests rots the moment an endpoint or a contract moves under it, and live execution is the only thing that catches that. The event test is the fork guard. A fork reaches this workflow only through pull_request, so excluding that event excludes every fork, and it also keeps signing off the pull-request path, where a flaky endpoint would train a reader to ignore a red check. The three run in a fixed order because each needs what the one before it wrote. --- .github/workflows/checks.yml | 41 ++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 575adad..91fb7d5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -299,21 +299,20 @@ jobs: with: node-version: '24' - # No key reaches this step. A starter that signs reads its two - # variables, finds neither, and exits zero naming the one it wanted, - # which is what a reader who cloned without keys sees too. - - name: Starters that need no key + # No key reaches this step, which is why it runs on every event. The + # two read-only starters exercise the live API here; the three + # signing ones prove the path a reader who cloned without keys + # takes, exiting zero and naming the variable they wanted. The + # install also leaves node_modules in place for the arm below. + - name: Every starter's tests, with no key in scope run: | - if [ ! -d starters ]; then - echo "No starters/ directory yet. This arm starts running with the pull request that lands it." - exit 0 - fi + set -euo pipefail for starter in starters/*/; do echo "::group::${starter}" ( cd "${starter}" - if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi + npm ci --no-audit --no-fund node --test ) echo "::endgroup::" @@ -328,27 +327,33 @@ jobs: # `github.event.pull_request.head.repo.full_name == github.repository` # beside it, or the keys follow the workflow into a fork's pull # request. + # + # The three run in this order because each needs what the one before + # it wrote: mint-asset mints into a collection create-collection + # made, and list-a-sale lists what mint-asset minted. - name: Starters that sign on WAX testnet if: github.event_name == 'push' || github.event_name == 'schedule' env: WAX_TESTNET_ACTOR: ${{ secrets.WAX_TESTNET_ACTOR }} WAX_TESTNET_PRIVATE_KEY: ${{ secrets.WAX_TESTNET_PRIVATE_KEY }} run: | - if [ ! -d starters ]; then - echo "No starters/ directory yet. This arm starts running with the pull request that lands it." - exit 0 - fi + set -euo pipefail - if [ -z "${WAX_TESTNET_ACTOR}" ] || [ -z "${WAX_TESTNET_PRIVATE_KEY}" ]; then + # Defaulted rather than read bare. The step env always defines + # both keys, but an unset one under `set -u` would abort the + # arm before it could report why. + if [ -z "${WAX_TESTNET_ACTOR:-}" ] || [ -z "${WAX_TESTNET_PRIVATE_KEY:-}" ]; then echo "::warning::WAX_TESTNET_ACTOR or WAX_TESTNET_PRIVATE_KEY is unset, so each signing starter skips itself" fi - for starter in starters/*/; do + # Named rather than globbed. The order is load-bearing, and a + # read-only starter run here would repeat what the arm above + # already proved. + for starter in create-collection mint-asset list-a-sale; do echo "::group::${starter}" ( - cd "${starter}" - if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi - node --test + cd "starters/${starter}" + node src/index.js ) echo "::endgroup::" done From a994c61aad80aacc5607e140ed4dd5717d288c9a Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 14:00:30 -0400 Subject: [PATCH 3/6] docs: open the tutorials tree with a path that proves each step The corpus documented every action and no route through them. A reader who arrived wanting to mint something had to assemble the order from four reference pages and a guide, and the site's Start here group rendered empty because the pin carried nothing to fill it. The tutorial charter is one guaranteed-success path with no decision in it, so every value the reader could have chosen is picked here and every step ends in a read that says whether it worked. The failure appendix carries the messages a first run actually produces, each traced to the page that documents the behavior behind it. The path runs on WAX testnet because that is where V2 is deployed, and its one external dependency is the public faucet the first step names. Account creation and funding are two separate faucet calls, which is why the tutorial spends a step on each. --- tutorials/first-collection.md | 331 ++++++++++++++++++++++++++++++++++ tutorials/starters.md | 29 +++ 2 files changed, 360 insertions(+) create mode 100644 tutorials/first-collection.md create mode 100644 tutorials/starters.md diff --git a/tutorials/first-collection.md b/tutorials/first-collection.md new file mode 100644 index 0000000..77a6445 --- /dev/null +++ b/tutorials/first-collection.md @@ -0,0 +1,331 @@ +--- +scope: The one path from an empty WAX testnet account to a minted asset, through faucet, session, collection, schema, and template, with a read proving each step +depends-on: [guides/signing.md, guides/asset-lifecycle.md, reference/atomicassets/structure.md, reference/sdk/atomicassets.md] +key-modules: + - "atomicassets-sdk (v2.1.1, 5c70c62): src/Actions/Generator.ts" + - "atomicassets-contract (v2.0.0-rc4): src/atomicassets.cpp" +--- + +# Mint your first asset on testnet + +One path from nothing to an asset you own on WAX testnet. Seven steps, each with a read that proves the step worked before the next one starts. Nothing here costs money and nothing here touches a mainnet. + +Every step is written out. Where a value could be anything, this page picks one, so there is no choice to make and no branch to get wrong. The [starters](starters.md) are the same code as directories you can clone. + +You need Node 22 or newer and `curl`. You do not need an API key: reading AtomicAssets state takes no credential, no account, and no registration, which is why every checkpoint below is a plain `curl` (see [Build a session and sign](../guides/signing.md#reads-need-no-key-no-account-and-no-registration), "Reads need no key, no account, and no registration"). + +## Step 1: create a WAX testnet account + +Pick a name of exactly 12 characters using only `a` to `z` and `1` to `5`. This page uses `firstmint111`. Substitute yours everywhere it appears. + +Twelve characters is not a style preference. The faucet rejects any other length and any character outside that set, with `{"msg": "failed, unsupported account name ''"}` and HTTP 400. + +Step 4 then reuses this name as the collection name. `createcol` accepts a collection name that is already a registered account, and that account's own authorization is then required as well, which is the signature you already have. It also means the collection name cannot collide with anyone else's, because the account name could not. + +The WAX Sweden guild runs a public faucet that creates a testnet account and returns its keys. It allows one account per 24 hours per caller. + +``` +curl 'https://faucet.waxsweden.org/create_account?firstmint111' +``` + +The response carries the account name and two key pairs, `active_key` and `owner_key`, each with a public and a private half. Step 3 signs with the `active` permission, so the private half of `active_key` is the one it needs. Save both somewhere outside the repository you are working in, because the faucet hands them back once and cannot recover them later. The same call buys the new account 8192 bytes of RAM, which is more than everything below writes. Faucet usage is documented at `https://waxsweden.org/create-testnet-account/`. + +### Checkpoint + +The account exists on chain: + +``` +curl -X POST https://waxtestnet.greymass.com/v1/chain/get_account \ + -d '{"account_name":"firstmint111"}' +``` + +HTTP 200 and a body opening with `"account_name":"firstmint111"`. An HTTP 400 carrying `"code":3060002` means the account does not exist yet, so the faucet call did not land; see [Chain RPC behavior](../reference/chain.md) for why retrying that response never helps. + +## Step 2: fund the account with testnet tokens + +Creating the account did not fund it. The same faucet issues testnet WAX on a separate call, 500 per call and up to 1000 per 24 hours. One call is enough for this tutorial. + +``` +curl 'https://faucet.waxsweden.org/get_token?firstmint111' +``` + +A successful call answers `{"msg": "succeeded"}`. + +### Checkpoint + +Re-read the account and look for the balance: + +``` +curl -X POST https://waxtestnet.greymass.com/v1/chain/get_account \ + -d '{"account_name":"firstmint111"}' +``` + +The body now carries a `core_liquid_balance` field reading `"500.00000000 WAX"`. It carries `ram_quota` and `ram_usage` too. Every row you write in steps 4 through 7 is billed to this account: the minter pays for an asset's row, not the recipient, so this account's RAM is what the mint spends. The four rows come to under 1000 bytes against the 8192 the faucet bought, so nothing here needs more RAM. See [Create a collection and mint assets](../guides/asset-lifecycle.md#mint-an-asset-mintasset) ("Mint an asset: mintasset") for the RAM payer rule on each action. + +## Step 3: install the packages and build the session + +Make a directory, install two packages, and write the session once. Everything after this imports it. + +``` +mkdir first-collection && cd first-collection +npm init -y +npm install @atomichub/atomicassets@2.1.1 @wharfkit/session @wharfkit/wallet-plugin-privatekey +``` + +Put your account name and private key in the environment rather than in the file: + +``` +export WAX_TESTNET_ACTOR=firstmint111 +export WAX_TESTNET_PRIVATE_KEY= +``` + +`session.mjs`: + +```js +import { Chains, Session } from '@wharfkit/session' +import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey' + +export const session = new Session({ + actor: process.env.WAX_TESTNET_ACTOR, + permission: 'active', + chain: Chains.WAXTestnet, + walletPlugin: new WalletPluginPrivateKey(process.env.WAX_TESTNET_PRIVATE_KEY), +}) +``` + +`Chains.WAXTestnet` is the chain WAX testnet runs V2 on. [Build a session and sign](../guides/signing.md) explains every field, the chain ids behind each constant, and how a browser signer drops into the same shape. + +### Checkpoint + +``` +node -e "import('./session.mjs').then(m => console.log(String(m.session.permissionLevel)))" +``` + +It prints `firstmint111@active`. If it throws `Either a permissionLevel or actor/permission must be provided when creating a new Session.` then `WAX_TESTNET_ACTOR` is unset in the shell that ran the command. Nothing has touched the network yet: this checkpoint is local. + +## Step 4: create the collection + +A collection is the top level of the data model. It owns the authorization list that decides who may create schemas, templates, and assets under it. + +`collection.mjs`: + +```js +import { ActionBuilder } from '@atomichub/atomicassets' +import { session } from './session.mjs' + +const builder = new ActionBuilder('atomicassets') + +const action = builder.createcol( + session.actor.toString(), // author + session.actor.toString(), // collection_name, the same 12 characters + true, // allow_notify + [session.actor.toString()], // authorized_accounts + [], // notify_accounts + 0.05, // market_fee, 5 percent + [{ key: 'name', value: ['string', 'First Mint'] }], +) + +const result = await session.transact({ action: { ...action, authorization: [session.permissionLevel] } }) +console.log(result.response.transaction_id) +``` + +``` +node collection.mjs +``` + +The call prints the transaction id the node accepted. The builder returns one `{account, name, data}` object and signs nothing; `session.transact` is what signs and broadcasts it, and the id comes back on `result.response`. The other three steps end the same way. + +### Checkpoint + +Read the collection straight off the chain: + +``` +curl -X POST https://waxtestnet.greymass.com/v1/chain/get_table_rows \ + -d '{"code":"atomicassets","scope":"atomicassets","table":"collections","json":true,"limit":1,"lower_bound":"firstmint111","upper_bound":"firstmint111"}' +``` + +One row comes back, carrying `"author":"firstmint111"`, `"authorized_accounts":["firstmint111"]`, and `"market_fee":"0.05000000000000000"`. + +An empty `rows` array here usually means the read reached a node that has not applied the block yet, so run it again before you conclude anything. Every chain checkpoint below behaves the same way. If it is still empty on the second read, the transaction did not land. + +The hosted indexer shows the same collection a moment later, with its attribute data already decoded: + +``` +curl 'https://test.wax.api.atomicassets.io/atomicassets/v1/collections/firstmint111' +``` + +That answers HTTP 200 with `"name":"First Mint"`. It answers HTTP 416 and `Collection not found` while the indexer is still behind the chain, which is why the chain read above is the checkpoint and this one is the confirmation. + +## Step 5: create the schema + +A schema is the ordered list of attribute names and types that every template and asset in the collection serializes against. Every schema must carry a `name` line of type `string`, so that line is first below. + +`schema.mjs`: + +```js +import { ActionBuilder } from '@atomichub/atomicassets' +import { session } from './session.mjs' + +const builder = new ActionBuilder('atomicassets') + +const action = builder.createschema( + session.actor.toString(), // authorized_creator + session.actor.toString(), // collection_name + 'cards', // schema_name + [ + { name: 'name', type: 'string' }, + { name: 'img', type: 'image' }, + { name: 'power', type: 'uint32' }, + ], +) + +const result = await session.transact({ action: { ...action, authorization: [session.permissionLevel] } }) +console.log(result.response.transaction_id) +``` + +``` +node schema.mjs +``` + +### Checkpoint + +Schemas are scoped to their collection, so the scope is the collection name: + +``` +curl -X POST https://waxtestnet.greymass.com/v1/chain/get_table_rows \ + -d '{"code":"atomicassets","scope":"firstmint111","table":"schemas","json":true,"limit":1}' +``` + +One row comes back reading `"schema_name":"cards"` with the three format lines in the order you sent them. That order is load-bearing: attribute values are stored by position in this vector, and a schema can only ever be appended to. See [AtomicAssets data model structure](../reference/atomicassets/structure.md#schemas) ("Schemas"). + +## Step 6: create the template + +A template holds the data every asset minted from it shares, so that data is stored and paid for once instead of once per asset. It also fixes whether those assets can be transferred and burned. + +`template.mjs`: + +```js +import { ActionBuilder, createAttributeMap } from '@atomichub/atomicassets' +import { session } from './session.mjs' + +const builder = new ActionBuilder('atomicassets') + +const immutable = createAttributeMap( + { name: 'First Card', power: 10 }, + { name: 'string', power: 'uint32' }, +) + +const action = builder.createtempl( + session.actor.toString(), // authorized_creator + session.actor.toString(), // collection_name + 'cards', // schema_name + true, // transferable + true, // burnable + 10, // max_supply + immutable, +) + +const result = await session.transact({ action: { ...action, authorization: [session.permissionLevel] } }) +console.log(result.response.transaction_id) +``` + +``` +node template.mjs +``` + +`createAttributeMap` turns plain values into the `{key, value}` pairs the contract expects, picking the ABI variant for each declared type. It throws before any transaction is built if a field has no type or an unrecognized one. + +### Checkpoint + +The action does not hand the new template id back to the caller, so read it. Templates are scoped to the collection, and the newest row is the last one: + +``` +curl -X POST https://waxtestnet.greymass.com/v1/chain/get_table_rows \ + -d '{"code":"atomicassets","scope":"firstmint111","table":"templates","json":true,"limit":1,"reverse":true}' +``` + +The row carries `"schema_name":"cards"`, `"max_supply":10`, `"issued_supply":0`, and a `template_id`. Copy that number. Step 7 needs it. + +## Step 7: mint the asset + +`mint.mjs`, with the template id from step 6 in place of `123456`: + +```js +import { ActionBuilder, createAttributeMap } from '@atomichub/atomicassets' +import { session } from './session.mjs' + +const builder = new ActionBuilder('atomicassets') + +const mutable = createAttributeMap({ power: 12 }, { power: 'uint32' }) + +const action = builder.mintasset( + session.actor.toString(), // authorized_minter + session.actor.toString(), // collection_name + 'cards', // schema_name + 123456, // template_id from step 6 + session.actor.toString(), // new_asset_owner, this account + [], // immutable_data + mutable, // mutable_data + [], // tokens_to_back +) + +const result = await session.transact({ action: { ...action, authorization: [session.permissionLevel] } }) +console.log(result.response.transaction_id) +``` + +``` +node mint.mjs +``` + +`tokens_to_back` is `[]` and stays `[]`. Native token backing is deprecated on V2, and a non-empty value aborts the mint. + +### Checkpoint + +Assets are scoped to their owner, so the scope is your account: + +``` +curl -X POST https://waxtestnet.greymass.com/v1/chain/get_table_rows \ + -d '{"code":"atomicassets","scope":"firstmint111","table":"assets","json":true,"limit":1,"reverse":true}' +``` + +One row comes back with an `asset_id` at or above 1099511627776, your `template_id`, `"ram_payer":"firstmint111"`, and a two-byte `mutable_serialized_data` array. The asset counter runs contract-wide and starts at 2^40, which is that number. Those two bytes are the `power` value packed against the schema format from step 5. + +The hosted indexer unpacks it for you: + +``` +curl 'https://test.wax.api.atomicassets.io/atomicassets/v1/assets?owner=firstmint111&limit=1' +``` + +HTTP 200. The response carries `"mutable_data":{"power":12}`, the value you just minted, and a merged `"data"` object reading `{"power": 10, "name": "First Card"}`. + +The 12 loses that collision on purpose. A template's immutable value for a key wins over the asset's own value for the same key, so the merged view shows the template's 10 while the asset's layer stays readable in its own field. That is the data precedence rule, and it is why the two fields disagree without either being wrong. Collections normally avoid the collision by keeping shared attributes on the template and per-asset attributes off it; this step sets `power` in both so you can see which one the merge keeps. + +You own an asset. Reading the template's `issued_supply` again now returns 1. + +## Next + +- [Attribute data precedence](../reference/atomicassets/data-precedence.md) settles which layer a value came from when a template and an asset both declare it. +- [Create a collection and mint assets](../guides/asset-lifecycle.md) is the same flow as a reference for every action, including editing mutable data, transferring, and burning. +- [Working with sales](../guides/sales.md) lists the asset you just minted. +- [Starters](starters.md) has this code as directories you can clone and run. + +## Appendix: what a first run hits + +Every message below is the contract's or the client's own text. The page in the last column is where that behavior is documented and cited. + +| What you see | What it means | Where it is documented | +| --- | --- | --- | +| HTTP 400 with `"code":3060002` on `get_account` | The account does not exist. The faucet call in step 1 did not land, or the name is misspelled. Retrying the read changes nothing. | [Chain RPC behavior](../reference/chain.md) | +| `{"msg": "failed, unsupported account name '...'"}` from the faucet | The name is not exactly 12 characters of `a` to `z` and `1` to `5`. | This page, step 1 | +| An empty `rows` array from `get_table_rows` right after a step | The read reached a node that has not applied the block yet. Run it again. Only a second empty answer means the transaction did not land. | [Chain RPC behavior](../reference/chain.md) | +| `Either a permissionLevel or actor/permission must be provided when creating a new Session.` | `WAX_TESTNET_ACTOR` is unset in this shell, so `actor` arrived as `undefined`. | [Build a session and sign](../guides/signing.md#construct-the-session) | +| `A collection with this name already exists` | Step 4 already succeeded, or the name is not the account you created. Skip to step 5. | [Create a collection and mint assets](../guides/asset-lifecycle.md#create-a-collection-createcol) | +| `The market_fee must be between 0 and 0.150000` | `market_fee` is outside 0 to 0.15. Step 4 uses 0.05. | [Create a collection and mint assets](../guides/asset-lifecycle.md#create-a-collection-createcol) | +| `Missing authorization for this collection` | The signing account is neither the collection's author nor on its `authorized_accounts` list. In this tutorial those are all one account, so this means step 4 ran under a different name. | [AtomicAssets data model structure](../reference/atomicassets/structure.md#authorization-and-the-24-account-cap) | +| `A format line with {"name": "name" and "type": "string"} needs to be defined for every schema` | The `schema_format` in step 5 lost its first line. | [Create a collection and mint assets](../guides/asset-lifecycle.md#create-a-schema-createschema) | +| `The template's maxsupply has already been reached` | This template has minted its 10. Create another template, or raise the cap on a new one. | [Create a collection and mint assets](../guides/asset-lifecycle.md#create-a-template-createtempl) | +| `Native backing has been deprecated on the AtomicAssets Contract` | `tokens_to_back` is not empty. It has to be `[]`. | [Backing tokens](../reference/atomicassets/backing-tokens.md) | +| A `SerializationError` naming a field, before any transaction is built | The builder's numeric guard rejected the value: a `template_id` that is not a whole int32, or a `max_supply` that is fractional or negative. A string that failed to parse arrives here as `NaN`. | [@atomichub/atomicassets SDK](../reference/sdk/atomicassets.md#numeric-parameters-are-checked-against-their-abi-type-and-throw) | +| `no type given for field 'x'` or `invalid type 'x' for field 'y'` | The second argument to `createAttributeMap` does not declare a type for every key in the first, or declares one the schema format does not use. | [@atomichub/atomicassets SDK](../reference/sdk/atomicassets.md) | +| HTTP 416 and `Collection not found` from the hosted API | The indexer has not caught up with the chain yet. The chain read is authoritative; wait and re-read. | [atomicassets-api HTTP API](../reference/api.md) | +| HTTP 429 and `{"success": false, "message": "Rate limit"}` | Too many reads against the hosted API from one address. The limit is per deployment and the response headers carry what is left. | [atomicassets-api HTTP API](../reference/api.md#rate-limits) | diff --git a/tutorials/starters.md b/tutorials/starters.md new file mode 100644 index 0000000..cebd1f3 --- /dev/null +++ b/tutorials/starters.md @@ -0,0 +1,29 @@ +--- +scope: The five runnable starter directories in this repository, what each one does on WAX testnet, and which of them needs a signing key before it does anything +depends-on: [tutorials/first-collection.md, guides/signing.md] +key-modules: [] +--- + +# Starters + +Five directories you can clone whole and run. Each one is a `package.json`, a `src/`, its own README, and a test that runs the thing rather than asserting about it. They are MIT licensed, separately from the prose in the rest of this repository. + +Two of them read and need nothing. Three of them sign on WAX testnet and read `WAX_TESTNET_ACTOR` and `WAX_TESTNET_PRIVATE_KEY` from the environment. With either of those unset, a signing starter exits zero and names the one it wanted, so a clone with no keys still runs green and tells you why it did nothing. [Mint your first asset on testnet](first-collection.md) walks the same ground step by step and shows you how to get an account and a key. + +| Starter | Needs | What it does | +| --- | --- | --- | +| [read-assets](../starters/read-assets/) | no key | Reads assets, templates, and schemas from the hosted API and prints the decoded attributes of one asset. | +| [storefront-read](../starters/storefront-read/) | no key | Reads open sales and auctions for a collection and prints what a buyer would pay and what the seller would keep. | +| [create-collection](../starters/create-collection/) | testnet key | Creates a collection and a schema under your account, the first half of the tutorial. | +| [mint-asset](../starters/mint-asset/) | testnet key | Creates a template and mints an asset from it into your own account. | +| [list-a-sale](../starters/list-a-sale/) | testnet key | Announces a sale for an asset you own and escrows it through an AtomicAssets offer. | + +Run one: + +``` +cd starters/read-assets +npm install +node --test +``` + +The two reading starters run anywhere. The three signing starters run against WAX testnet, which is where the V2 contracts are deployed, so nothing they do costs anything or touches a mainnet. Use a testnet key and only a testnet key: [Build a session and sign](../guides/signing.md#construct-the-session) covers where that key lives and why the environment is the only place it belongs. From d8856c696f24461606b7d94bcaba3553c8aa5af9 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 14:00:46 -0400 Subject: [PATCH 4/6] docs: give the corpus the concepts tree its navigation already declares Reference says what is true and guides say how to do a named task. Neither says why the protocol is shaped the way it is, so a reader who wanted to decide whether to build on it had to infer the design from field lists. The site declares an Understand group for exactly this and it rendered empty. Each page explains one shape and links the reference page that validated every fact it leans on, which is why none of them carries a validation tier or a ledger row: a concepts page restates, and the page it restates is the one graded. Two of the six exist because an integrator arrives holding a different mental model. The comparison against the EVM single-token standard answers the question that gets asked first, and the royalty page answers the one that gets asked next, which is whether a resale royalty is a request a storefront can decline. --- concepts/compared-with-erc721.md | 53 ++++++++++++++++++++++++++++++ concepts/four-level-model.md | 55 ++++++++++++++++++++++++++++++++ concepts/one-order-book.md | 39 ++++++++++++++++++++++ concepts/ownership-on-chain.md | 39 ++++++++++++++++++++++ concepts/reading-atomic-data.md | 55 ++++++++++++++++++++++++++++++++ concepts/royalties.md | 47 +++++++++++++++++++++++++++ 6 files changed, 288 insertions(+) create mode 100644 concepts/compared-with-erc721.md create mode 100644 concepts/four-level-model.md create mode 100644 concepts/one-order-book.md create mode 100644 concepts/ownership-on-chain.md create mode 100644 concepts/reading-atomic-data.md create mode 100644 concepts/royalties.md diff --git a/concepts/compared-with-erc721.md b/concepts/compared-with-erc721.md new file mode 100644 index 0000000..0e2e20b --- /dev/null +++ b/concepts/compared-with-erc721.md @@ -0,0 +1,53 @@ +--- +scope: "How AtomicAssets and ERC-721 differ by mechanism: where attribute data lives, who deploys a contract, what a mint costs, and how a resale royalty is applied" +depends-on: [reference/atomicassets/structure.md, reference/atomicassets/tables.md, reference/atomicassets/actions.md, reference/media.md, reference/atomicmarket/fees-and-royalties.md] +key-modules: [] +--- + +# AtomicAssets next to ERC-721 + +Both designs let an account own a unique item and prove it on chain. They diverge on four mechanisms, and the differences are consequences of those, not of anyone being careless. + +Read this as two halves. Everything about AtomicAssets links to the page in this repository that validated it. Everything about ERC-721 is what that standard and its common extensions specify, which this repository has not validated and does not cite. + +## Where the attributes live + +ERC-721 keeps the owner of each id on chain and puts the description of the item behind a pointer. Its metadata extension defines `tokenURI`, a per-id string returning a URI, and the document at that URI holds the name, the image reference, and the traits. The contract does not read that document and cannot check it. + +AtomicAssets stores attributes on chain, as fields on contract tables. A template carries `immutable_serialized_data`, an asset carries its own immutable and mutable data, and a schema declares the names and types those bytes decode against. See [AtomicAssets data model structure](../reference/atomicassets/structure.md) and [AtomicAssets tables](../reference/atomicassets/tables.md#assets). + +The honest limit is that media is a pointer on both sides. The chain stores no image bytes and has no media column: a media attribute is an ordinary schema attribute whose value is a bare content reference, and a CID on chain is a claim about content rather than a guarantee any node still serves it. See [Media conventions](../reference/media.md). What differs is the rest of it. A trait an application reads is a chain read on one side and a fetch of somebody's document on the other. + +## Who deploys a contract + +Under ERC-721 a collection is a contract. Each project deploys its own, at its own address, with its own code, and every integrator adds that address before it can show anything from it. + +Under AtomicAssets a collection is a row. The `collections` table is scoped to the contract's own account, template and asset ids come from counters that run across the whole contract, and the format that collection data serializes against is one process-wide value rather than one per collection. Creating a collection is an action, not a deployment, and it needs no code review because there is no new code. See [AtomicAssets data model structure](../reference/atomicassets/structure.md#collections). + +The collection is still the unit of control. Its `author` and its `authorized_accounts` list decide who may create and edit schemas, templates, and assets under it, and its `market_fee` is the collection's own number. See [AtomicAssets data model structure](../reference/atomicassets/structure.md#authorization-and-the-24-account-cap). + +The trade goes both ways. A shared contract means an indexer, a signer, or a storefront that reads one collection reads all of them, and an upgrade that lands reaches every collection at once. It also means a collection cannot change the rules for itself: custom behavior belongs in a contract of your own that reacts to notifications rather than in a fork of this one. + +## What a mint costs + +An ERC-721 mint costs gas, priced by demand for block space at the moment it runs, spent and not returned. + +An AtomicAssets mint costs storage. `mintasset` creates the asset row and bills the RAM to `authorized_minter`, not to the recipient, so a minter without enough RAM blocks its own mint whatever the recipient holds. See [AtomicAssets actions](../reference/atomicassets/actions.md#mintasset). + +Two properties follow from storage rather than gas. The bill is a stake rather than a burn: the row's RAM is released when the row is erased, and `burnasset` erases it. And the bill is transferable. The `ram_payer` field is independent of the owner: it moves to an authorized editor on `setassetdata`, the V2 reassignment actions move it deliberately, and `payofferram` lets a service take over an offer's RAM so its users do not have to hold any. + +The template level exists for this reason. Data shared by many assets is stored and paid for once instead of once per asset, which is a saving with no counterpart when every mint writes its own record. + +## How a resale royalty is applied + +ERC-2981 gives a contract a `royaltyInfo` view that returns a recipient and an amount for a given sale price. It is a signal. The token contract does not run it during a transfer and cannot make a payment happen, so whether the recipient is paid depends on the marketplace that settles the trade choosing to read it and act on it. + +AtomicMarket does the arithmetic itself. Every sale, auction claim, and buyoffer acceptance routes through one payout function that deducts the maker marketplace fee, the taker marketplace fee, the collection fee, and any active bonus fees, credits each recipient, and gives the seller the remainder. The collection's rate is read from the collection row at settlement, not taken from the listing. See [AtomicMarket fees and royalties](../reference/atomicmarket/fees-and-royalties.md#every-settlement-stacks-four-fee-layers-before-the-seller-is-paid). + +The boundary matters as much as the mechanism. The arithmetic lives in AtomicMarket, and it is settling through AtomicMarket that applies it. AtomicAssets stores the collection's `market_fee` and never spends it: `transfer` moves the asset and deducts nothing, so two accounts trading directly pay no royalty. What the design buys is that a seller cannot choose a storefront that pays the collection less, because the number is not the storefront's to apply. See [Royalties are settlement math](royalties.md). + +## Next + +- [Why an asset has four levels](four-level-model.md) is the data model this page keeps referring to. +- [Royalties are settlement math](royalties.md) is the settlement side in full. +- [Mint your first asset on testnet](../tutorials/first-collection.md) is the shortest way to see the difference rather than read about it. diff --git a/concepts/four-level-model.md b/concepts/four-level-model.md new file mode 100644 index 0000000..089646a --- /dev/null +++ b/concepts/four-level-model.md @@ -0,0 +1,55 @@ +--- +scope: Why AtomicAssets splits one item across a collection, a schema, a template, and an asset, what each level owns, and the two different things inheritance means +depends-on: [reference/atomicassets/structure.md, reference/atomicassets/data-precedence.md, reference/atomicassets/tables.md] +key-modules: [] +--- + +# Why an asset has four levels + +An item on AtomicAssets is not one row. It is a row at each of four levels, and the split is there to make two things cheap: storage, and the decision about who is allowed to write. + +```mermaid +flowchart TD + C["Collection: authority and market fee"] + S["Schema: attribute names and types"] + T["Template: data shared by many assets"] + A["Asset: one item, scoped to its owner"] + C -->|holds| S + S -->|serializes| T + T -->|"flags and shared data"| A + S -->|"no template"| A +``` + +## What each level owns + +A collection is the top-level grouping. Every schema, template, and asset belongs to exactly one collection, and the collection's `authorized_accounts` list is the boundary that decides who may create or edit any of them. The collection also carries the `market_fee` that AtomicMarket reads at settlement. + +A schema is an ordered list of attribute names and types. It never holds a value. Templates and assets in the collection serialize their data against it, and it can only ever be appended to, because a value is stored by its position in that list rather than by its name. + +A template holds the data that many assets share, so that data is stored and paid for once instead of once per asset. That is the cost argument the level exists for. A template also fixes the `transferable` and `burnable` flags for everything minted from it. + +An asset is one owned item. It carries its collection and schema, fixed at mint, its own immutable data set once, and its own mutable data an authorized editor can replace later. + +See [AtomicAssets data model structure](../reference/atomicassets/structure.md) for the full field list at each level, and [AtomicAssets tables](../reference/atomicassets/tables.md) for the column-by-column reference. + +## The levels are not one chain of ownership + +Templates are optional. `mintasset` takes `template_id = -1` and mints an asset that carries only its own data, which is why the diagram above has an edge that skips the template entirely. + +Template ids come from one counter for the whole contract, not one per collection, so a template id is unique everywhere and says nothing about which collection it belongs to on its own. + +The scopes do not nest the way the picture suggests. Collections sit in a table scoped to the contract account, schemas and templates are scoped to their collection, and assets are scoped to their current owner. That last one is the consequential one: there is no index from a collection to its assets on chain, so listing everything in a collection is something the hosted API does by joining across owners, not something a chain read can do. See [Query the API and chain tables](../guides/querying-the-api.md#read-chain-tables-with-get_table_rows) ("Read chain tables with get_table_rows"). + +## Inheritance means two different things + +The word covers two mechanisms that behave differently, and confusing them is the usual first mistake. + +The `transferable` and `burnable` flags are inherited by the chain. An asset minted from a template takes those flags, and the contract enforces them: a transfer of an asset whose template says `transferable: false` fails. + +Attribute data is not inherited by the chain at all. Each table stores its own layer as raw bytes and the contract never merges them. A template's immutable value for a key wins over an asset-level value for the same key, but that ranking is applied by whatever reads the chain, not by the contract. An indexer, an API, or a client library deserializes each layer and combines them. See [Attribute data precedence](../reference/atomicassets/data-precedence.md) for the ordering and for what a reader has to implement. + +## Next + +- [AtomicAssets data model structure](../reference/atomicassets/structure.md) is the reference behind every claim on this page. +- [Attribute data precedence](../reference/atomicassets/data-precedence.md) settles a name collision between two layers. +- [Mint your first asset on testnet](../tutorials/first-collection.md) builds one of each level in order. diff --git a/concepts/one-order-book.md b/concepts/one-order-book.md new file mode 100644 index 0000000..f88d0a4 --- /dev/null +++ b/concepts/one-order-book.md @@ -0,0 +1,39 @@ +--- +scope: Why every AtomicMarket listing lands in one contract's tables rather than a per-storefront book, and how a storefront still earns a fee on a trade it brought +depends-on: [reference/atomicmarket/marketplaces.md, reference/atomicmarket/tables.md, reference/atomicmarket/fees-and-royalties.md, guides/sales.md] +key-modules: [] +--- + +# One book, many storefronts + +AtomicMarket is one contract account, and its sales, auctions, and buyoffers all live in tables scoped to that account. A listing is not filed under the site that created it, because there is nowhere on the row to file it. + +## Where a listing actually sits + +The `sales`, `auctions`, `buyoffers`, and `tbuyoffers` tables are each scoped to the contract's own account, and none of them partitions rows by the site that wrote them. Listing ids come from one counter table, so a sale id is unique across every caller rather than per storefront. See [AtomicMarket tables](../reference/atomicmarket/tables.md) for the row shapes and their scopes. + +The consequence is structural rather than a policy anyone enforces: a site reading the sales table reads every open sale, including the ones it did not create, and a site writing one writes into the same table everyone else reads. + +## How a storefront gets paid anyway + +Attribution is a parameter on the action, not a partition of the data. + +A site calls `regmarket` once to register a marketplace name against a creator account. After that, a listing action takes a `maker_marketplace` and its counterparty action takes a `taker_marketplace`, and the contract rejects a name that is not registered in either slot. The maker comes from whoever created the listing and is stored on the row. The taker comes from the counterparty: on a sale or a buyoffer that is the call that settles, and on an auction it is the bid, so an auction's taker is fixed well before the claim actions run the payout. + +A marketplace only ever supplies one side of a trade per action. There is no call that carries both, so the site that listed an asset and the site that sold it are two rows in one settlement, each paid its own layer. + +Registration is what makes a site creditable, not what makes it able to trade. The contract seeds a default marketplace under the empty name, which is what a caller passes when it has no marketplace of its own. + +Fees are not pushed. Both cuts are credited to the marketplace creator's internal balance, and the creator calls `withdraw` to take them. See [AtomicMarket marketplaces](../reference/atomicmarket/marketplaces.md#marketplace-fees-collect-into-the-balances-table-not-a-direct-transfer) ("Marketplace fees collect into the balances table, not a direct transfer"). + +## What that costs and what it buys + +The cost is that the chain does not remember which site closed a sale. A completed sale's taker marketplace lives only in the settlement trace, never in table state, so an indexer that wants that fact has to read traces. Auctions are the exception and keep theirs on the row until it is claimed or cancelled. + +What it buys is that a listing does not have to be re-published anywhere to be reachable, and that a seller's asset is not held hostage by the site that listed it. A sale is a lazy escrow: `announcesale` records a row and moves nothing, and the asset stays with the seller until a buyer settles. See [Working with sales](../guides/sales.md). + +## Next + +- [AtomicMarket marketplaces](../reference/atomicmarket/marketplaces.md) covers registration, attribution, and fee crediting in full. +- [Royalties are settlement math](royalties.md) is what the collection's own layer of that settlement does. +- [AtomicMarket tables](../reference/atomicmarket/tables.md) is the row reference for every listing type. diff --git a/concepts/ownership-on-chain.md b/concepts/ownership-on-chain.md new file mode 100644 index 0000000..7118c5c --- /dev/null +++ b/concepts/ownership-on-chain.md @@ -0,0 +1,39 @@ +--- +scope: What the chain records when an account owns an AtomicAssets asset, why a transfer moves a row between scopes, and the one part of an asset that is a reference +depends-on: [reference/atomicassets/structure.md, reference/atomicassets/tables.md, reference/media.md, reference/atomicassets/actions.md] +key-modules: [] +--- + +# Ownership is a table scope + +An account owns an asset because the asset's row lives in a table scoped to that account. There is no separate ledger of who holds what, and no field on the row saying who the owner is. The scope is the record. + +## What a transfer moves + +`transfer` moves the row out of the sender's scope and into the recipient's, carrying the asset's data with it. The immutable data is copied verbatim; no action rewrites it, on transfer or afterward. + +Two things follow that surprise people. + +The first is that a recipient who has never held an AtomicAssets asset has no scope yet, so one has to be created, and the sender pays for it. The transfer fails outright if the sender cannot cover that. + +The second is that paying for an asset's storage and owning it are separate facts. The row carries a `ram_payer` field that is independent of the scope: the minter pays first, an authorized editor takes the bill over on `setassetdata`, and the V2 reassignment actions move it again without moving the asset. See [AtomicAssets actions](../reference/atomicassets/actions.md#ram-payer-reassignment-replaces-descoped-custodial-rentals) ("RAM-payer reassignment (replaces descoped custodial rentals)"). + +## Why that is stronger than a row in a database + +The interesting property is not that the data is stored somewhere durable. It is that changing it takes a signature the contract checks, and the rules about which signature are themselves on chain. + +Only the owner can move the asset, and only when the template allows it. Only an account on the collection's `authorized_accounts` list can change the asset's mutable data, and that list is a field on the collection row that only the author can edit. Nobody operating the service that displays the asset is in that path at all. See [AtomicAssets data model structure](../reference/atomicassets/structure.md#authorization-and-the-24-account-cap) ("Authorization and the 24-account cap"). + +## The caveat: media is a reference, not the thing + +The chain stores no image bytes and has no media column. Media is an ordinary schema attribute whose value is an IPFS reference, under a field name that is collection convention rather than contract rule. The stored value is a bare content reference with no scheme and no host, and a minter can put a plain `https://` URL there instead, because nothing in the contract constrains the string. + +So the attribute is chain state and the picture is not. A client resolves the reference at render time by choosing a gateway, and whether the referenced content is still served is a separate question the chain does not answer. As [Media conventions](../reference/media.md#resolving-a-reference-to-a-fetchable-url) ("Resolving a reference to a fetchable URL") puts it, a CID on chain is a claim about content, not a guarantee any node still serves it. + +That is the honest shape of the guarantee. What the asset is, who owns it, what its attributes say, and who may change any of that are chain state. The bytes a renderer draws are pinned by whoever chose to pin them. + +## Next + +- [Media conventions](../reference/media.md) covers the field names, the value shapes, and how to resolve one. +- [AtomicAssets tables](../reference/atomicassets/tables.md#assets) is the column reference for the asset row. +- [Offers: the native two-sided trade flow](../guides/offers.md) is how two accounts swap assets without a market. diff --git a/concepts/reading-atomic-data.md b/concepts/reading-atomic-data.md new file mode 100644 index 0000000..93a6369 --- /dev/null +++ b/concepts/reading-atomic-data.md @@ -0,0 +1,55 @@ +--- +scope: The two paths that read AtomicAssets and AtomicMarket state, what a chain read can and cannot answer, and why a raw row needs its schema to mean anything +depends-on: [guides/querying-the-api.md, reference/api.md, reference/atomicassets-api.md, reference/atomicassets/serialization.md, guides/signing.md] +key-modules: [] +--- + +# Two ways to read the same state + +Everything in this ecosystem is readable twice: straight off the chain, or through the hosted indexer that follows the chain. They answer different questions, and neither needs a credential. + +```mermaid +flowchart LR + CH["Contract tables on chain"] + ND["Public node: get_table_rows"] + IX["atomicassets-api indexer"] + DB["Indexer database"] + HT["Hosted HTTP API"] + CL["Your client"] + CH --> ND + CH --> IX + IX --> DB + DB --> HT + ND -->|"head state, packed bytes"| CL + HT -->|"joined and decoded, slight lag"| CL +``` + +## Neither path asks for a key + +There is no API key to obtain, no account to create, and no registration step on the read path. A key signs a transaction, and nothing about reading needs one. That is worth knowing before you design around an onboarding step that does not exist. See [Build a session and sign](../guides/signing.md#reads-need-no-key-no-account-and-no-registration) ("Reads need no key, no account, and no registration"). + +## What a chain read is good for + +`get_table_rows` against a public node gives you head state with nothing in between. It is the right read when you need to know what is true right now, and it is the only read that is authoritative rather than derived. + +Its limit is the scoping. The `assets` table is scoped by owner and carries no collection or template index, so there is no chain-side path from a collection to the assets in it without already knowing every owner. Reading one specific asset works fine, given its owner as the scope. Enumerating a collection does not. See [Query the API and chain tables](../guides/querying-the-api.md#read-chain-tables-with-get_table_rows) ("Read chain tables with get_table_rows") for that and for the numeric-key and large-integer traps that come with it. + +## What the hosted API is good for + +The indexer reads the chain into a database and serves it over HTTP, which is what makes the queries a chain read cannot do possible: filtering assets by collection or template, joining an asset to its template and schema, and returning attribute data already decoded. + +Three properties come with that. The market list routes cap `limit` at 100 on the reference deployment and reject a larger value with HTTP 400 rather than clamping it, so paging code bounds the value and uses `page`. Rate limiting is a deployment setting, present on the reference deployment and absent where an operator has not configured it. And the data is behind the chain by however far the indexer is behind, which is small but never zero. See [atomicassets-api HTTP API](../reference/api.md) and [atomicassets-api indexer](../reference/atomicassets-api.md). + +## Why a raw row is not readable on its own + +A chain read hands back attribute data as a byte array, and nothing in that byte array is self-describing. The format stores a position number and a raw value per attribute, and keeps the names and types once, in the schema. Decoding means walking the schema's format list in step with the bytes. + +So a reader that goes straight to the chain has to fetch the schema too, and keep it current, because a schema can be extended. That is the work the hosted API is doing for you when it returns a `data` object instead of a byte array. See [AtomicAssets attribute serialization](../reference/atomicassets/serialization.md#serializing-and-deserializing-off-chain) ("Serializing and deserializing off-chain"). + +Writing is the easy direction. An off-chain caller building an action does not need the codec at all: it sends the attribute map as ordinary action data and the contract serializes it. + +## Next + +- [Query the API and chain tables](../guides/querying-the-api.md) is the working reference for both paths, with the host table for testnet. +- [AtomicAssets attribute serialization](../reference/atomicassets/serialization.md) is the wire format, if you are writing the decoder. +- [@atomichub/atomicassets SDK](../reference/sdk/atomicassets.md) has both readers and the codec already written. diff --git a/concepts/royalties.md b/concepts/royalties.md new file mode 100644 index 0000000..efd9da3 --- /dev/null +++ b/concepts/royalties.md @@ -0,0 +1,47 @@ +--- +scope: Why a resale royalty on AtomicMarket is arithmetic the contract runs inside the settlement action, what it deducts and in what order, and who each share reaches +depends-on: [reference/atomicmarket/fees-and-royalties.md, reference/atomicmarket/actions.md, reference/atomicmarket/tables.md, reference/atomicassets/v2-upgrade.md] +key-modules: [] +--- + +# Royalties are settlement math + +A resale royalty here is not a request a storefront chooses to honor. It is a subtraction the market contract performs inside the settlement action, before the seller is credited with anything. + +## What a settlement deducts + +Every sale, auction claim, and buyoffer acceptance routes through one payout function, which deducts in order: the maker marketplace fee, the taker marketplace fee, the collection fee, and any active bonus fees. Each recipient is credited, and the remainder becomes the seller's balance. + +The last step is a hard floor. If the stacked fees would leave the seller nothing or less, the whole settlement reverts rather than paying out a zero. See [AtomicMarket fees and royalties](../reference/atomicmarket/fees-and-royalties.md#every-settlement-stacks-four-fee-layers-before-the-seller-is-paid) ("Every settlement stacks four fee layers before the seller is paid"). + +Note that bonus fees are one of those layers and there can be several at once. WAX carries at least one on both mainnet and testnet, so an estimate built from the maker, taker, and collection layers alone comes out over what the seller actually receives. Which fees are active, at what rate, and to which recipient is table state: read `bonusfees` on the `atomicmarket` account rather than assuming a number. See [AtomicMarket fees and royalties](../reference/atomicmarket/fees-and-royalties.md#bonus-fees-are-additive-marketplace-incentives-layered-on-top) ("Bonus fees are additive marketplace incentives layered on top"). + +## The rate is read when the sale settles, not when it was listed + +The listing actions read the collection's `market_fee` at creation and store it on the row, but only so an indexer can display it. At settlement the contract reads the collection row again and uses whatever the fee is at that moment. + +A collection author who changes the fee changes it for every already-open listing at once. The buyer still pays the listed price, so what moves is the split between the seller and the collection. See [AtomicMarket fees and royalties](../reference/atomicmarket/fees-and-royalties.md#the-collection-fee-applies-at-execution-time-not-at-listing-time) ("The collection fee applies at execution time, not at listing time"). + +## Where the collection's share goes + +Without a royalty configuration the whole collection fee goes to the collection author, which is the pre-V2 behavior. + +With one, the contract splits that share across up to three categories: founders, the asset's own template, and any attribute rules the asset matches. The three weights are relative to each other rather than fractions of one. A category with no payee for this particular asset is dropped and the rest are renormalized against each other, so no share is left stranded. + +Rounding is accounted for rather than discarded. Any integer remainder from dividing a share falls through to the collection author and is reported on its own. The payouts logged for one settlement sum to exactly the collection fee that settlement charged, to the unit. + +## Reading what a sale actually paid + +The four royalty log actions are inline actions the contract sends to itself with no notification to any recipient, which is deliberate: a payee's contract cannot assert inside a handler and block somebody else's settlement. It also means a notification-driven indexer never sees them, and only a trace-reading pipeline does. + +For a client, the payout record is the indexer's per-listing logs route rather than a recomputation. `GET /atomicmarket/v1/sales/{sale_id}/logs` returns the logged entries with their `{recipient, amount}` payouts, and the same `/logs` suffix serves auctions, buyoffers, and template buyoffers. Recomputing the split from the royalty tables can disagree with what was paid, because the configuration and the fee can both move between listing and settlement. See [AtomicMarket fees and royalties](../reference/atomicmarket/fees-and-royalties.md#the-royalty-log-actions-are-trace-only-and-dust-always-reconciles) ("The royalty log actions are trace-only and dust always reconciles"). + +## Where this runs + +The split engine is a V2 feature. WAX testnet runs V2 and a settlement there has been observed paying founders, template, and attribute shares that sum exactly to the collection fee. WAX mainnet still runs V1, where the collection fee goes to the author undivided. See [AtomicAssets V2 upgrade](../reference/atomicassets/v2-upgrade.md#deployment-status) ("Deployment status"). One consequence reaches every client: the hosted royalty routes answer 416 for every mainnet collection, which is the normal empty answer there rather than a per-collection fact. See [atomicassets-api HTTP API](../reference/api.md#the-royalty-routes-answer-416-when-a-collection-has-no-config) ("The royalty routes answer 416 when a collection has no config"). + +## Next + +- [AtomicMarket fees and royalties](../reference/atomicmarket/fees-and-royalties.md) is the reference behind every number here. +- [AtomicMarket actions](../reference/atomicmarket/actions.md#royalty-split-configuration-v2-only) is how a collection configures a split. +- [One book, many storefronts](one-order-book.md) is the marketplace layer of the same settlement. From 917391dc4018407307585e569dbb80ba112fa1bb Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 14:00:53 -0400 Subject: [PATCH 5/6] docs: route the two new trees from AGENTS.md by outcome Eight pages landed with no row in the routing table, so an agent reading this file could reach none of them and would go on inferring a mint order from the reference tree. Each new row sits beside the reference page it hands off to, because that is where an agent already stops. The ledger sentence gains the reason the two trees take no row in it. Without that, the validation-consistency job's silence on an ungraded tutorial reads as a gap rather than as the design. --- AGENTS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 52f852c..9454ec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,10 @@ Start here. Find the outcome you are working toward below, read the file on that | To do this | Read | | --- | --- | +| Go from an empty testnet account to a minted asset, one step at a time | `tutorials/first-collection.md` | +| Clone runnable code for a read, a mint, or a listing | `tutorials/starters.md` | +| Weigh AtomicAssets against the single-token standard on an EVM chain | `concepts/compared-with-erc721.md` | +| Choose between a chain table read and the hosted API before writing the reader | `concepts/reading-atomic-data.md` | | Read assets, sales, and collections over HTTP, with no key and no account | `guides/querying-the-api.md` | | Look up one hosted-API endpoint: pagination caps, lifecycle states, rate limits, the two sales routes | `reference/api.md` | | Classify a chain RPC error, and tell a missing account apart from a broken node | `reference/chain.md` | @@ -20,8 +24,10 @@ Start here. Find the outcome you are working toward below, read the file on that | Read a table or check an authority through the client library, or migrate off eosjs | `reference/wharfkit.md` | | Subscribe to live asset, sale, and auction events over Socket.IO | `reference/api-streaming.md` | | Show an asset's image or video from what the chain actually stores | `reference/media.md` | +| Say what the chain guarantees about an owner, and what it does not guarantee about media | `concepts/ownership-on-chain.md` | | Sign anything: build the session, pick the chain, install the signer | `guides/signing.md` | | Mint an asset on testnet, from collection and schema through transfer and burn | `guides/asset-lifecycle.md` | +| Work out why the data is split across four levels, and what a level below inherits | `concepts/four-level-model.md` | | Work out what a collection, a schema, a template, and an asset each own | `reference/atomicassets/structure.md` | | Look up one AtomicAssets action: parameters, authorization, RAM payer, V2 changes | `reference/atomicassets/actions.md` | | Read or index one AtomicAssets table row | `reference/atomicassets/tables.md` | @@ -35,10 +41,12 @@ Start here. Find the outcome you are working toward below, read the file on that | Offer to buy an asset nobody has listed, or accept such an offer | `guides/buyoffers.md` | | Swap assets with another account without going through the market | `guides/offers.md` | | Fund a buyer's balance, or find out why a payout is still parked | `guides/deposits.md` | +| Say why a resale royalty is paid at settlement rather than requested of a storefront | `concepts/royalties.md` | | Work out what a settlement actually pays: fee stack, royalty splits, the dust rule | `reference/atomicmarket/fees-and-royalties.md` | | Look up one AtomicMarket action | `reference/atomicmarket/actions.md` | | Read or index one AtomicMarket table row | `reference/atomicmarket/tables.md` | | Find what V2 changed on the market, bundle retirement and the guards included | `reference/atomicmarket/v2-changes.md` | +| Work out why every listing lands in one contract, and how a storefront still earns | `concepts/one-order-book.md` | | Run a storefront that earns maker and taker fees | `reference/atomicmarket/marketplaces.md` | | Size RAM for a busy marketplace, and find out who pays for which row | `reference/atomicmarket/ram.md` | | Hand assets to someone who has no account yet, through a claim link | `guides/links.md` | @@ -54,7 +62,7 @@ Start here. Find the outcome you are working toward below, read the file on that Read the routed file in full rather than searching it. The facts there encode behavior no method signature carries: which account is billed for a row, which read answers with a null instead of an error, which flag lets a transaction commit while delivering nothing. -Take each section at face value and do not extrapolate past what it states. A page says what was checked, and what it does not say was not checked. `validation-log.md` records how every page was validated and against what. +Take each section at face value and do not extrapolate past what it states. A page says what was checked, and what it does not say was not checked. `validation-log.md` records how every page was validated and against what. It grades `reference/` and `guides/` only: a tutorial's claim is that its steps run, and a concepts page restates facts the pages it links already carry, so neither takes a row there. Re-check any fact that names a version when that dependency moves. The two SDK pages are pinned to `@atomichub/atomicassets` 2.1.1, read at tag `v2.1.1`, and `@atomichub/atomicmarket` 2.4.1, read at tag `v2.4.1`; the client-library page is pinned to `@wharfkit/antelope` 1.1.1, and the AtomicAssets and AtomicMarket contract pages to `v2.0.0-rc4` and `v2.0.0-rc2`. A fact read at one of those pins is a fact about that release, not about the package name. From d32879523b47497716846b8f4139220a71e164ee Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 14:01:08 -0400 Subject: [PATCH 6/6] docs: draw the flows whose ordering prose states worst Four pages open on a multi-step contract sequence a reader has to reassemble from paragraphs before any of the detail below means anything: which state an auction is in, what a sale row does before an asset moves, what a settlement subtracts and in what order, and how the four data levels hang together. A Mermaid fence renders natively on GitHub and through rehype-mermaid on the site, so one source serves both surfaces with no binary asset and the diagram stays inside the fidelity gate that already compares fenced blocks byte for byte. Labels are plain corpus prose for the same reason: they are read by the banned-term and casing rules once the site renders them into SVG text. The four-level diagram is the concepts page's, mirrored here so the reference opens on the same picture the explainer taught. --- guides/auctions.md | 13 +++++++++++++ guides/sales.md | 12 ++++++++++++ reference/atomicassets/structure.md | 12 ++++++++++++ reference/atomicmarket/fees-and-royalties.md | 11 +++++++++++ 4 files changed, 48 insertions(+) diff --git a/guides/auctions.md b/guides/auctions.md index ae4c172..eab8d2b 100644 --- a/guides/auctions.md +++ b/guides/auctions.md @@ -13,6 +13,19 @@ The full lifecycle of an AtomicMarket auction (V2 baseline): announcing, transfe Unlike a sale, an auction takes actual custody of the asset: the seller transfers it to the `atomicmarket` contract account, and it sits there until claimed or the auction is cancelled before any bid lands. Bids are deposit-backed: a bidder's AtomicMarket balance is debited when they bid, and refunded if outbid. See [Balances and deposits](deposits.md) for the transfer-with-memo deposit flow; this guide only shows where a step requires a sufficient balance. Each write below runs through a `session` built in [Build a session and sign](signing.md). +```mermaid +stateDiagram-v2 + [*] --> Announced: announceauct + Announced --> Active: transfer with memo auction + Active --> Ended: end_time passes + Ended --> Settled: auctclaimbuy and auctclaimsel + Announced --> Cancelled: cancelauct + Active --> Cancelled: cancelauct, before any bid + Ended --> Cancelled: cancelauct, no bid landed + Settled --> [*] + Cancelled --> [*] +``` + ## Announce an auction `announceauct` creates the auction row with a starting bid and duration. It moves nothing yet. diff --git a/guides/sales.md b/guides/sales.md index 8279cc7..a6c8617 100644 --- a/guides/sales.md +++ b/guides/sales.md @@ -15,6 +15,18 @@ A sale is a lazy-accept escrow: `announcesale` only records a row, it never move Purchases and bids draw on the buyer's AtomicMarket balance rather than moving tokens directly. See [Balances and deposits](deposits.md) for the transfer-with-memo deposit flow and balance mechanics; this guide only shows where a step requires a sufficient balance. Each write below runs through a `session` built in [Build a session and sign](signing.md). +```mermaid +flowchart TD + N["announcesale: sale row created, nothing moves"] + O["createoffer: asset offered to the market contract"] + P["purchasesale: buyer pays, offer accepted, asset moves"] + X["cancelsale: row erased, asset never moved"] + N --> O + O --> P + N --> X + O --> X +``` + ## Announce a sale `announcesale` creates the sale row. It moves nothing: the asset stays with the seller until the escrow offer is created and accepted. diff --git a/reference/atomicassets/structure.md b/reference/atomicassets/structure.md index a54de70..a6667f6 100644 --- a/reference/atomicassets/structure.md +++ b/reference/atomicassets/structure.md @@ -8,6 +8,18 @@ key-modules: ["atomicassets-contract (v2.0.0-rc4): src/atomicassets.cpp, include The `atomicassets` contract organizes assets in four levels: collections group schemas and templates and hold the authorization rules, schemas declare the attribute formats a collection's data serializes to, templates carry immutable data shared by many assets, and assets are the individual items an account owns. Baseline behavior below is V2, tag `v2.0.0-rc4` of `atomicassets-contract` (the release pinned for both testnets: `include/atomicassets.hpp`, `src/atomicassets.cpp`); "Changed in V2" notes call out where V1 differs enough to matter to an integrator. +```mermaid +flowchart TD + C["Collection: authority and market fee"] + S["Schema: attribute names and types"] + T["Template: data shared by many assets"] + A["Asset: one item, scoped to its owner"] + C -->|holds| S + S -->|serializes| T + T -->|"flags and shared data"| A + S -->|"no template"| A +``` + ## Collections A collection is the top-level grouping: every schema, template, and asset belongs to exactly one collection, and the collection's `authorized_accounts` list is the authorization boundary for creating and editing them. diff --git a/reference/atomicmarket/fees-and-royalties.md b/reference/atomicmarket/fees-and-royalties.md index 0fa6d45..906c513 100644 --- a/reference/atomicmarket/fees-and-royalties.md +++ b/reference/atomicmarket/fees-and-royalties.md @@ -8,6 +8,17 @@ key-modules: ["atomicmarket-contract (v2.0.0-rc2): src/atomicmarket.cpp, include The full fee model applied to a sale, auction, or buyoffer settlement on the `atomicmarket` contract: the maker and taker marketplace fees, the protocol-configured market fee bounds, the AtomicAssets collection fee and when it is read, and the V2 royalty split engine that divides the collection's share among founders, template owners, and attribute-matched recipients. +```mermaid +flowchart TD + P["Buyer pays the listing price"] + P --> M["Deduct maker marketplace fee"] + M --> T["Deduct taker marketplace fee"] + T --> C["Deduct collection fee"] + C --> B["Deduct active bonus fees"] + B --> S["Remainder credited to the seller"] + C --> R["Collection fee split: founders, template, attributes"] +``` + ## Every settlement stacks four fee layers before the seller is paid A sale, auction claim, or buyoffer acceptance all route through `internal_payout_sale`, which deducts, in order: the maker marketplace fee, the taker marketplace fee, the collection fee, and any active bonus fees, crediting each recipient before adding the remainder to the seller's balance. The maker and taker rates come from the `config` singleton's `maker_market_fee` and `taker_market_fee` fields (0.01 each by default) and are computed as `fee_rate * quantity.amount` cast to an integer token amount, credited to the registered marketplace's `creator` account. A strict `seller_cut_quantity.amount > 0` assertion is the final backstop: if the stacked fees would leave the seller nothing (or a negative remainder), the whole settlement reverts rather than silently zeroing the payout.