@meddleware/token-deployer-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.claude/move-language-reference/SKILL.md +445 -0
  2. package/.claude/skills/move-language-reference/SKILL.md +445 -0
  3. package/.claude/skills/sui-move-bootcamp/SKILL.md +128 -0
  4. package/.claude/sui-move-bootcamp/SKILL.md +128 -0
  5. package/.editorconfig +8 -0
  6. package/.env.example +75 -0
  7. package/.env.production +7 -0
  8. package/.gitattributes +1 -0
  9. package/.github/workflows/ci.yml +34 -0
  10. package/.github/workflows/token-deployer-e2e.yml +64 -0
  11. package/.oxfmtrc.json +5 -0
  12. package/.oxlintrc.json +10 -0
  13. package/.vscode/extensions.json +9 -0
  14. package/AGENTS.md +39 -0
  15. package/CLAUDE.md +159 -0
  16. package/LICENSE +121 -0
  17. package/README.md +234 -0
  18. package/env.d.ts +13 -0
  19. package/index.html +13 -0
  20. package/package.json +67 -0
  21. package/playwright.config.ts +30 -0
  22. package/public/CNAME +1 -0
  23. package/public/_headers +12 -0
  24. package/public/_redirects +1 -0
  25. package/public/favicon.ico +0 -0
  26. package/public/favicon.svg +5 -0
  27. package/public/og-image.png +0 -0
  28. package/public/robots.txt +3 -0
  29. package/public/sitemap.xml +8 -0
  30. package/scripts/e2e-browser.mjs +15 -0
  31. package/scripts/e2e-deploy.mjs +222 -0
  32. package/scripts/e2e-localnet.mjs +65 -0
  33. package/scripts/e2e-walrus-browser.mjs +115 -0
  34. package/scripts/gen-og-image.mjs +31 -0
  35. package/scripts/publish-localnet.mjs +55 -0
  36. package/scripts/regen-template.sh +45 -0
  37. package/scripts/sync-template-src.sh +36 -0
  38. package/scripts/verify-bytecode.mjs +49 -0
  39. package/src/App.vue +41 -0
  40. package/src/__tests__/App.spec.js +11 -0
  41. package/src/components/AppNotice.vue +15 -0
  42. package/src/components/ColorModeControl.vue +81 -0
  43. package/src/components/ConfigForm.vue +192 -0
  44. package/src/components/DeployProgress.vue +46 -0
  45. package/src/components/FieldHint.vue +79 -0
  46. package/src/components/FormField.vue +28 -0
  47. package/src/components/FormSelect.vue +43 -0
  48. package/src/components/GithubPush.vue +100 -0
  49. package/src/components/IconPicker.vue +410 -0
  50. package/src/components/LicensePicker.vue +182 -0
  51. package/src/components/ResultPanel.vue +88 -0
  52. package/src/components/ReviewPanel.vue +69 -0
  53. package/src/components/TermsDialog.vue +81 -0
  54. package/src/components/TokenDeployerView.vue +115 -0
  55. package/src/components/WalletBar.vue +108 -0
  56. package/src/components/WalrusBlobBrowser.vue +188 -0
  57. package/src/components/WalrusIconUpload.vue +205 -0
  58. package/src/composables/useAccessGate.ts +122 -0
  59. package/src/composables/useColorMode.ts +76 -0
  60. package/src/composables/useWalrusRelay.ts +21 -0
  61. package/src/config.ts +185 -0
  62. package/src/index.ts +1 -0
  63. package/src/lib/accessGate.ts +141 -0
  64. package/src/lib/buildPublishTx.ts +192 -0
  65. package/src/lib/deploy.ts +168 -0
  66. package/src/lib/deployExecutor.ts +61 -0
  67. package/src/lib/errors.ts +21 -0
  68. package/src/lib/form.ts +75 -0
  69. package/src/lib/generatePackage.ts +312 -0
  70. package/src/lib/github.ts +146 -0
  71. package/src/lib/licenses.ts +122 -0
  72. package/src/lib/readClient.ts +17 -0
  73. package/src/lib/template.ts +131 -0
  74. package/src/lib/types.ts +69 -0
  75. package/src/lib/validation.ts +172 -0
  76. package/src/lib/walrus-constants.ts +15 -0
  77. package/src/lib/walrus.ts +144 -0
  78. package/src/main.ts +154 -0
  79. package/src/move-template/sui_token_template.mv +0 -0
  80. package/src/move-template/template.ts +31 -0
  81. package/src/styles.css +384 -0
  82. package/src/template-src/files.json +10 -0
  83. package/test-results/.last-run.json +4 -0
  84. package/tests/accessGate.test.ts +108 -0
  85. package/tests/buildPublishTx.test.ts +243 -0
  86. package/tests/deploy.test.ts +81 -0
  87. package/tests/errors.test.ts +32 -0
  88. package/tests/generatePackage.test.ts +182 -0
  89. package/tests/github.test.ts +61 -0
  90. package/tests/licenses.test.ts +78 -0
  91. package/tests/template.test.ts +93 -0
  92. package/tests/templateArtifact.test.ts +61 -0
  93. package/tests/templateParity.test.ts +55 -0
  94. package/tests/useAccessGate.test.ts +101 -0
  95. package/tests/useWallet.test.ts +74 -0
  96. package/tests/useWalrusRelayGating.test.ts +30 -0
  97. package/tests/validation.test.ts +117 -0
  98. package/tests/walrusUpload.test.ts +93 -0
  99. package/tests/walrus_url_patch.test.ts +26 -0
  100. package/tsconfig.json +19 -0
  101. package/typedoc.json +34 -0
  102. package/vite.config.ts +65 -0
  103. package/vitest.config.ts +10 -0
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @meddleware/token-deployer-ui
2
+
3
+ A browser front-end that lets anyone deploy their own Sui coin — a client-side
4
+ equivalent of `blockchain/sui/sui-token-template`. **Everything runs in the
5
+ browser**: the user's wallet signs and pays gas, and nothing is compiled or
6
+ signed on a server. The user also gets the full, verifiable Move source package,
7
+ and can optionally push it to a new GitHub repo in their own account.
8
+
9
+ ## How it works
10
+
11
+ 1. Ships one pre-compiled `sui_token_template` Move module whose metadata fields
12
+ are DISTINCT named constants, so they can be patched
13
+ ([src/move-template/](src/move-template/)).
14
+ 2. In the browser, [`@mysten/move-bytecode-template`](https://www.npmjs.com/package/@mysten/move-bytecode-template)
15
+ deserialises the module; the identifiers + constant pool are patched to the
16
+ user's values ([src/lib/template.ts](src/lib/template.ts)) and it is re-serialised.
17
+ 3. A publish PTB ([src/lib/buildPublishTx.ts](src/lib/buildPublishTx.ts)) publishes
18
+ the module, applies the UpgradeCap policy, and splits a trivial fee to the
19
+ operator treasury. A follow-up finalize PTB mints the initial supply and applies
20
+ the supply/metadata policies when they differ from the defaults.
21
+ 4. The same inputs generate a full, downloadable source package
22
+ ([src/lib/generatePackage.ts](src/lib/generatePackage.ts)) that is **byte-identical**
23
+ to the CLI generator's output, so the on-chain package is source-verifiable.
24
+ 5. Licenses are fetched live from SPDX ([src/lib/licenses.ts](src/lib/licenses.ts)); an
25
+ optional icon upload goes to Walrus ([src/lib/walrus.ts](src/lib/walrus.ts)); and an
26
+ optional GitHub push uses the user's own token ([src/lib/github.ts](src/lib/github.ts)).
27
+
28
+ ## Security & trust model
29
+
30
+ - **No backend, no key custody.** The only network calls are to the Sui RPC, the
31
+ user's wallet, SPDX (`raw.githubusercontent.com`), the Walrus relay/aggregator
32
+ (only if the icon uploader is used), and GitHub (only if the push is used).
33
+ - **No endorsement.** The tool is neutral; it does not vet or endorse created
34
+ tokens or their creators. This is stated in the review, result, and footer UI.
35
+ - **Defence in depth.** User input is validated at the form
36
+ ([src/lib/validation.ts](src/lib/validation.ts)) *and* re-asserted before it is
37
+ substituted into Move source / shell scripts
38
+ ([src/lib/generatePackage.ts](src/lib/generatePackage.ts)) and before it is
39
+ patched into the bytecode ([src/lib/template.ts](src/lib/template.ts)), so no
40
+ quote/backslash/control character or reserved Move keyword can ever produce an
41
+ injectable package.
42
+ - **Fee integrity.** The fee is split from the user's own gas coin to the operator
43
+ treasury inside the publish PTB and cannot be redirected at runtime. A production
44
+ build **fails** if the treasury is unset (see below), so revenue can't silently
45
+ burn to `0x0`.
46
+ - **GitHub token hygiene.** The optional PAT is `type=password`, `autocomplete=off`,
47
+ sent only to `api.github.com` over HTTPS, never stored or logged, and cleared as
48
+ soon as the push finishes.
49
+
50
+ ## Develop
51
+
52
+ ```bash
53
+ npm install
54
+ npm run dev # Vite dev server
55
+ npm run test # Vitest unit tests (61 tests)
56
+ npm run type-check # vue-tsc
57
+ npm run build # production bundle -> dist/ (runs the treasury guard)
58
+ npm run docs # TypeDoc API reference -> docs/api/
59
+ ```
60
+
61
+ ## Operator configuration
62
+
63
+ Committed defaults live in [.env.production](.env.production) (build) and
64
+ [.env.development](.env.development) (dev server). Override per environment in the
65
+ host dashboard. See [.env.example](.env.example) for the full list.
66
+
67
+ | Variable | Purpose | Default |
68
+ | --- | --- | --- |
69
+ | `VITE_FEE_TREASURY_TESTNET` / `_MAINNET` | Operator treasury that receives the fee. **A production build fails if either is the zero address** (bypass with `VITE_ALLOW_UNSET_TREASURY=1`). | committed operator address |
70
+ | `VITE_FEE_MIST` | Flat fee split from the user's gas coin. | `1000000000` (1 SUI) |
71
+ | `VITE_RPC_TESTNET` / `_MAINNET` | JSON-RPC endpoint (set a paid RPC for production). | public endpoints |
72
+ | `VITE_PUBLISH_GAS_BUDGET` | Publish gas budget (MIST). | `500000000` |
73
+ | `VITE_PUBLIC_URL` | Site origin for canonical/OG/JSON-LD URLs. | `https://tokens.meddleware.co.uk` |
74
+ | `VITE_WALRUS_RELAY_TESTNET` / `_MAINNET` | Your own upload relay (to collect the tip). Unset → public Mysten relay (earns nothing). | public relay |
75
+ | `VITE_WALRUS_MAX_TIP_MIST` | Client-side max relay tip. **Must exceed your relay's tip** or uploads fail. | `50000000` (0.05 SUI) |
76
+ | `VITE_ICON_MAX_BYTES` / `VITE_ICON_ALLOWED_TYPES` | Client icon size/type gate (UX only). | `102400` / PNG,JPEG,WebP,SVG |
77
+
78
+ ## Walrus icon upload relay (optional — for per-upload revenue)
79
+
80
+ Icon uploads go through a Walrus **upload relay**. Using the public Mysten relay
81
+ works out of the box but earns you nothing. To charge users per upload, run your
82
+ own relay and configure `VITE_WALRUS_RELAY_*` — the tip is paid **on-chain by the
83
+ user's wallet to your address**, no custom backend.
84
+
85
+ **How the tip is safe (walrus SDK 1.1.7 + `crates/walrus-upload-relay`):** the tip
86
+ is baked into the same tx the user signs and bound to the blob by
87
+ `blobDigest ‖ SHA256(nonce) ‖ size`. The relay verifies the on-chain payment —
88
+ amount checked against the *actual received body length* — **before** the expensive
89
+ fan-out. A user cannot strip the tip or underpay for a large blob; either way the
90
+ relay rejects and you incur no fan-out cost. The relay performs **no on-chain tx**,
91
+ so you never pay gas; storage (WAL) is paid by the user at registration.
92
+
93
+ **Host it (not a Cloudflare Worker).** The relay RS2-encodes blobs and opens
94
+ hundreds of concurrent connections to storage nodes — beyond the Workers model. Run
95
+ the stock `mysten/walrus-upload-relay` Docker image on a container host (Fly.io with
96
+ scale-to-zero bounds idle cost; or a small VPS + Caddy). Your frontend stays static
97
+ (GitHub Pages / Cloudflare Pages); the relay is a separate backend.
98
+
99
+ ```bash
100
+ docker run -p 3000:3000 \
101
+ -v $HOME/.config/walrus/walrus_upload_relay_config.yaml:/opt/walrus/walrus_upload_relay_config.yaml \
102
+ -v $HOME/.config/walrus/client_config.yaml:/opt/walrus/client_config.yaml \
103
+ mysten/walrus-upload-relay --context testnet \
104
+ --walrus-config /opt/walrus/client_config.yaml \
105
+ --server-address 0.0.0.0:3000 \
106
+ --relay-config /opt/walrus/walrus_upload_relay_config.yaml
107
+ ```
108
+
109
+ **Set a `linear` tip with margin** in `walrus_upload_relay_config.yaml`: `base` ≥
110
+ per-request overhead (RPC + connection) and `encoded_size_mul_per_kib` above your
111
+ egress cost/KiB, plus a fat margin (it also absorbs SUI/fiat drift and storage-node
112
+ retry amplification). Tip `address` = your wallet. Keep `VITE_WALRUS_MAX_TIP_MIST`
113
+ comfortably above this tip (the SDK hard-fails uploads if the tip exceeds it). The
114
+ relay reads its tip only at **startup**, so changing it means rewrite-YAML + restart.
115
+
116
+ **Protect it at the edge (the authoritative controls).** Put the relay behind
117
+ Cloudflare (proxied DNS) with:
118
+
119
+ - a **hard request-body cap ~256 KB** — icons are <100 KB, but the relay otherwise
120
+ buffers up to a hardcoded **1 GiB** body into RAM *before* the tip check (the one
121
+ real abuse vector: ingress/RAM/RPC on rejected uploads; no egress/gas);
122
+ - a **per-IP** WAF rate-limit on `/v1/blob-upload-relay` (per-*wallet* limiting isn't
123
+ worth it — see [CLAUDE.md](CLAUDE.md) "Walrus relay economics & deferred decisions");
124
+ - **origin locked to Cloudflare** (Authenticated Origin Pulls or a Cloudflare Tunnel)
125
+ so the origin IP can't be hit directly.
126
+
127
+ Open CORS means anyone can use your relay, but every served upload paid a size-scaled
128
+ tip, so third-party use is *profitable* as long as the tip keeps margin.
129
+
130
+ ## Deploy (Cloudflare Pages)
131
+
132
+ - Build command: `npm run build` · Output directory: `dist`
133
+ - [public/_headers](public/_headers) sets security headers + asset caching;
134
+ [public/_redirects](public/_redirects) provides the SPA fallback.
135
+ - SEO: [index.html](index.html) has env-aware canonical/OG/Twitter/JSON-LD, a real
136
+ `public/og-image.png` (regenerate with `npm run gen:og-image`), and a crawlable
137
+ static fallback; `public/robots.txt` + `public/sitemap.xml` are included.
138
+ - Before going live, confirm the treasury vars are set (the build enforces this)
139
+ and point the subdomain at the deployment.
140
+
141
+ ## Keeping the template in sync (single source of truth)
142
+
143
+ The shipped bytecode and the downloadable source both derive from
144
+ `@meddleware/sui-token-template` (`optionalDependencies`). After changing that template, re-run:
145
+
146
+ ```bash
147
+ npm run regen:template # recompiles + refreshes src/move-template/*
148
+ npm run sync:template # refreshes src/template-src/files.json
149
+ npm run verify:template # bytecode round-trip + provenance/parity tests
150
+ ```
151
+
152
+ Two tests enforce this invariant in CI so it can never silently drift:
153
+
154
+ - [tests/templateParity.test.ts](tests/templateParity.test.ts) — `files.json`
155
+ matches the canonical template sources verbatim.
156
+ - [tests/templateArtifact.test.ts](tests/templateArtifact.test.ts) — the shipped
157
+ `.mv` still contains the identifiers/constants the patcher expects.
158
+
159
+ ## Verification scripts
160
+
161
+ - `scripts/publish-localnet.mjs` — patch → publish → finalize against a local Sui node
162
+ (node, no browser).
163
+ - `scripts/e2e-deploy.mjs` — the **network-parametrized real-chain deploy e2e** (headless
164
+ browser + injected wallet): drives the full UI, does a REAL publish, waits for real
165
+ confirmation, checks the result panel, downloads the source zip, and verifies coin type +
166
+ operator fee on-chain. Covers exactly what the mocked Cypress suite cannot. `scripts/e2e-browser.mjs`
167
+ is a thin localnet wrapper over it.
168
+ - `scripts/e2e-walrus-browser.mjs` — the Walrus icon uploader end-to-end on testnet
169
+ with an injected wallet.
170
+
171
+ ## Real-chain e2e / launch runbook
172
+
173
+ The Playwright mocked suite (`npm run test:e2e`) mocks all RPC via `VITE_E2E=1`, so it can prove
174
+ the form/wallet/publish UI but **not** on-chain confirmation, the result panel, or the source
175
+ download. The real-chain harness fills that gap. It is **manual** — never wired into automatic CI — because testnet/mainnet runs
176
+ publish a real token and spend real SUI.
177
+
178
+ ```bash
179
+ npm run e2e:localnet # fresh keypair, localnet faucet — no real funds; run this freely
180
+ npm run e2e:testnet # real testnet publish — needs a funded key (see below)
181
+ npm run e2e:mainnet # real mainnet publish — additionally needs E2E_MAINNET_CONFIRM=1
182
+ ```
183
+
184
+ Each script builds the app (production build, so the treasury guard runs) and serves `dist` before
185
+ driving it. `E2E_NETWORK` selects the chain; the runner derives the app network (localnet borrows
186
+ testnet mode with the RPC pointed at the local node).
187
+
188
+ **Prerequisites for a real (testnet/mainnet) run:**
189
+
190
+ | Env | Purpose |
191
+ | --- | --- |
192
+ | `SUI_PRIV` | bech32 `suiprivkey1…` for a **funded** deployer key. Export the CLI key: `sui keytool export --key-identity <addr>`. Never faucet-funded on a real network. |
193
+ | `VITE_FEE_TREASURY_TESTNET` / `_MAINNET` | Operator treasury baked into the build (already set in `.env.production`). The build fails if it is the zero address. |
194
+ | `E2E_FEE_ADDR` | The treasury to watch for the fee-delta assertion — set it equal to the built treasury. If unset, the fee check is skipped (the app still charges the fee). |
195
+ | `VITE_RPC_TESTNET` / `_MAINNET` and `E2E_RPC` | JSON-RPC endpoint for the build (browser) and the node-side verifier. Use a JSON-RPC-capable endpoint — the official testnet fullnode is gRPC-only. |
196
+ | `E2E_MAINNET_CONFIRM=1` | **Mainnet only.** Without it the mainnet runner aborts cleanly (fail-safe default) so it can never publish by accident. |
197
+
198
+ A full fee-charging run needs the key to hold **≥ gas + fee** (~0.5 SUI gas + 1 SUI fee = ~1.5 SUI).
199
+ For a first dry run you can lower `VITE_FEE_MIST` or leave the treasury unset (`VITE_ALLOW_UNSET_TREASURY=1`)
200
+ so only ~0.5 SUI of gas is needed.
201
+
202
+ Example (testnet):
203
+
204
+ ```bash
205
+ export SUI_PRIV="$(sui keytool export --key-identity 0xYOURADDR --json | jq -r .exportedPrivateKey)"
206
+ VITE_RPC_TESTNET=https://sui-testnet-rpc.publicnode.com \
207
+ E2E_RPC=https://sui-testnet-rpc.publicnode.com \
208
+ VITE_FEE_TREASURY_TESTNET=0xYOURTREASURY \
209
+ E2E_FEE_ADDR=0xYOURTREASURY \
210
+ npm run e2e:testnet
211
+ ```
212
+
213
+ > **Do not run `e2e:testnet` until all other launch work is complete** — a premature published token
214
+ > can attract traders before you are ready.
215
+
216
+ ### MVP manual verification checklist
217
+
218
+ The human backstop for anything the harness does not assert (run once against testnet before launch):
219
+
220
+ - [ ] Connect a real wallet (testnet), confirm the network selector shows testnet.
221
+ - [ ] Fill the form; the coin-type preview updates as the module name changes.
222
+ - [ ] Review → Confirm & deploy; wallet prompts to sign and pay gas.
223
+ - [ ] "Confirming on-chain" reaches **done** (no timeout).
224
+ - [ ] Result panel shows the correct coin type, package id, and cap ids.
225
+ - [ ] The SuiScan package link opens the deployed package.
226
+ - [ ] "Download source package (.zip)" produces a buildable Move package.
227
+ - [ ] "Deploy another" resets the form.
228
+ - [ ] (If used) the icon upload returns a Walrus aggregator URL that renders the image.
229
+ - [ ] The operator treasury received the fee.
230
+
231
+ ## API reference
232
+
233
+ `npm run docs` generates a TypeDoc site under `docs/api/` covering the deployment
234
+ library (`src/lib`), composables, and config. See [typedoc.json](typedoc.json).
package/env.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
6
+ export default component
7
+ }
8
+
9
+ // Raw ?url imports (Vite) for the move-bytecode-template wasm asset.
10
+ declare module '*?url' {
11
+ const src: string
12
+ export default src
13
+ }
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <link rel="icon" href="/favicon.ico">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Vite App</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@meddleware/token-deployer-ui",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": { "default": "./src/index.ts" }
7
+ },
8
+ "publishConfig": { "access": "public" },
9
+ "scripts": {
10
+ "dev": "vite",
11
+ "build": "vue-tsc --noEmit && vite build",
12
+ "preview": "vite preview",
13
+ "type-check": "vue-tsc --noEmit",
14
+ "test:unit": "vitest",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "build:e2e": "VITE_E2E=1 vite build",
18
+ "test:e2e": "playwright test",
19
+ "test:e2e:ui": "playwright test --ui",
20
+ "e2e:install": "playwright install --with-deps chromium firefox",
21
+ "e2e:localnet": "VITE_RPC_TESTNET=${VITE_RPC_TESTNET:-http://127.0.0.1:9000} VITE_FEE_TREASURY_TESTNET=${VITE_FEE_TREASURY_TESTNET:-0xfee0000000000000000000000000000000000000000000000000000000000000} vite build && start-server-and-test preview http://localhost:4173 'E2E_NETWORK=localnet node scripts/e2e-deploy.mjs'",
22
+ "e2e:testnet": "vite build && start-server-and-test preview http://localhost:4173 'E2E_NETWORK=testnet node scripts/e2e-deploy.mjs'",
23
+ "e2e:mainnet": "vite build && start-server-and-test preview http://localhost:4173 'E2E_NETWORK=mainnet node scripts/e2e-deploy.mjs'",
24
+ "lint": "oxlint . --fix",
25
+ "format": "oxfmt src/",
26
+ "docs": "typedoc",
27
+ "regen:template": "bash scripts/regen-template.sh",
28
+ "sync:template": "bash scripts/sync-template-src.sh",
29
+ "gen:og-image": "node scripts/gen-og-image.mjs",
30
+ "verify:template": "node scripts/verify-bytecode.mjs && vitest run tests/templateArtifact.test.ts tests/templateParity.test.ts"
31
+ },
32
+ "dependencies": {
33
+ "@meddleware/wallet-adapter": "^0.0.7",
34
+ "@meddleware/walrus-relay": "^0.1.0",
35
+ "@mysten/bcs": "~2.1.0",
36
+ "@mysten/move-bytecode-template": "~0.4.0",
37
+ "@mysten/sui": "^2.31.0",
38
+ "@mysten/walrus": "~1.2.0",
39
+ "fflate": "^0.8.3",
40
+ "vue": "^3.5.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "~24.12.2",
44
+ "@vitejs/plugin-vue": "^6.0.8",
45
+ "@vitejs/plugin-vue-jsx": "^5.1.6",
46
+ "@playwright/test": "^1.56.0",
47
+ "@vitest/eslint-plugin": "^1.6.23",
48
+ "@vue/test-utils": "^2.4.0",
49
+ "jsdom": "^29.1.1",
50
+ "oxfmt": "^0.59.0",
51
+ "oxlint": "~1.76.0",
52
+ "playwright": "^1.62.0",
53
+ "start-server-and-test": "^3.0.11",
54
+ "typedoc": "^0.28.20",
55
+ "typescript": "~6.0.0",
56
+ "vite": "^8.1.5",
57
+ "vite-plugin-vue-devtools": "^8.1.5",
58
+ "vitest": "^4.1.10",
59
+ "vue-tsc": "~3.3.0"
60
+ },
61
+ "optionalDependencies": {
62
+ "@meddleware/sui-token-template": "^1.0.0"
63
+ },
64
+ "engines": {
65
+ "node": "^22.18.0 || >=24.12.0"
66
+ }
67
+ }
@@ -0,0 +1,30 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ // Mocked-RPC UI e2e. Standardised on Playwright (robust; replaces the former
4
+ // Cypress mocked suite, which exits SIGILL in the sandbox). The app is built with
5
+ // VITE_E2E=1 so main.ts registers the mock wallet + fetch mock (tree-shaken from
6
+ // prod). Real-chain e2e stays in scripts/e2e-deploy.mjs (npm run e2e:localnet…).
7
+ const PORT = 4173
8
+
9
+ export default defineConfig({
10
+ testDir: './e2e',
11
+ fullyParallel: true,
12
+ forbidOnly: !!process.env.CI,
13
+ retries: process.env.CI ? 2 : 0,
14
+ reporter: 'list',
15
+ use: {
16
+ baseURL: `http://localhost:${PORT}`,
17
+ viewport: { width: 1280, height: 800 },
18
+ trace: 'on-first-retry',
19
+ },
20
+ webServer: {
21
+ command: `npm run build:e2e && npm run preview -- --port ${PORT} --strictPort`,
22
+ url: `http://localhost:${PORT}/`,
23
+ reuseExistingServer: !process.env.CI,
24
+ timeout: 180_000,
25
+ },
26
+ projects: [
27
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
28
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
29
+ ],
30
+ })
package/public/CNAME ADDED
@@ -0,0 +1 @@
1
+ sui-token-deployer.meddleware.co.uk
@@ -0,0 +1,12 @@
1
+ # Cloudflare Pages headers. Security baseline; connect-src stays broad because
2
+ # wallet extensions, Walrus relays, and per-network Sui RPCs vary.
3
+ /*
4
+ X-Content-Type-Options: nosniff
5
+ Referrer-Policy: strict-origin-when-cross-origin
6
+ X-Frame-Options: DENY
7
+ Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()
8
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
9
+
10
+ # Long-cache hashed build assets.
11
+ /assets/*
12
+ Cache-Control: public, max-age=31536000, immutable
@@ -0,0 +1 @@
1
+ /* /index.html 200
Binary file
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Token Deployer">
2
+ <circle cx="16" cy="16" r="15" fill="#2f6feb"/>
3
+ <circle cx="16" cy="16" r="15" fill="none" stroke="#1b4fc0" stroke-width="2"/>
4
+ <text x="16" y="21" font-family="system-ui, sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">S</text>
5
+ </svg>
Binary file
@@ -0,0 +1,3 @@
1
+ User-agent: *
2
+ Allow: /
3
+ Sitemap: https://tokens.meddleware.co.uk/sitemap.xml
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://tokens.meddleware.co.uk/</loc>
5
+ <changefreq>monthly</changefreq>
6
+ <priority>1.0</priority>
7
+ </url>
8
+ </urlset>
@@ -0,0 +1,15 @@
1
+ // Thin wrapper: localnet deploy e2e. The real implementation is the network-parametrized
2
+ // `e2e-deploy.mjs`; this just pins E2E_NETWORK=localnet so the historical entry point and its
3
+ // localnet defaults (fresh keypair + faucet, 0xfee… treasury, 1 SUI fee assertion) keep working.
4
+ //
5
+ // Prereq (as before): build the app with the testnet RPC pointed at the local node and a
6
+ // treasury set, then serve dist on APP_URL (default http://localhost:4173):
7
+ // VITE_RPC_TESTNET=http://127.0.0.1:9000 VITE_FEE_TREASURY_TESTNET=<addr> vite build
8
+ //
9
+ // Equivalent to `E2E_NETWORK=localnet node scripts/e2e-deploy.mjs` — or just `npm run e2e:localnet`.
10
+
11
+ process.env.E2E_NETWORK = 'localnet'
12
+ // Preserve the legacy FEE_ADDR override name for anyone still setting it.
13
+ if (process.env.FEE_ADDR && !process.env.E2E_FEE_ADDR) process.env.E2E_FEE_ADDR = process.env.FEE_ADDR
14
+
15
+ await import('./e2e-deploy.mjs')
@@ -0,0 +1,222 @@
1
+ // Network-parametrized headless-browser deploy e2e. Loads the built app, injects a
2
+ // wallet-standard wallet backed by a node-held keypair, drives the full UI, and performs a
3
+ // REAL publish against the target chain — then verifies coin type + operator fee on-chain.
4
+ //
5
+ // This is the single source of truth for the real-chain deploy path; `e2e-browser.mjs` is a
6
+ // thin localnet wrapper over it. It covers exactly what the mocked Cypress suite cannot: real
7
+ // confirmation, the result panel, the source-zip download, and on-chain verification.
8
+ //
9
+ // Two axes matter and are DISTINCT:
10
+ // - E2E_NETWORK = the physical chain the RPC talks to: localnet | testnet | mainnet.
11
+ // - APP_NETWORK = the network the app runs in (its only UI options are testnet/mainnet;
12
+ // it drives the `sui:<net>` chain label and which fee treasury applies).
13
+ // localnet is run as APP_NETWORK=testnet with the testnet RPC overridden to the local node,
14
+ // so the app believes it is on testnet while publishing to 127.0.0.1:9000. mainnet is the
15
+ // only case that changes the in-UI network selector.
16
+ //
17
+ // The npm scripts (e2e:localnet / e2e:testnet / e2e:mainnet) build the app with the matching
18
+ // VITE_RPC_* / VITE_FEE_TREASURY_* envs and serve dist on APP_URL before invoking this runner.
19
+
20
+ import { readFileSync, existsSync } from 'node:fs'
21
+ import { chromium } from 'playwright'
22
+ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
23
+ import { requestSuiFromFaucetV2 } from '@mysten/sui/faucet'
24
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
25
+ import { Transaction } from '@mysten/sui/transactions'
26
+ import { toBase64 } from '@mysten/sui/utils'
27
+
28
+ const ZERO_ADDR = '0x' + '0'.repeat(64)
29
+
30
+ const NETWORK = process.env.E2E_NETWORK || 'localnet'
31
+ if (!['localnet', 'testnet', 'mainnet'].includes(NETWORK)) {
32
+ fail(`E2E_NETWORK must be localnet|testnet|mainnet (got "${NETWORK}")`)
33
+ }
34
+
35
+ // The app only ever runs in testnet or mainnet mode; localnet borrows testnet mode.
36
+ const APP_NETWORK = NETWORK === 'mainnet' ? 'mainnet' : 'testnet'
37
+
38
+ const DEFAULT_RPC = {
39
+ localnet: 'http://127.0.0.1:9000',
40
+ testnet: 'https://sui-testnet-rpc.publicnode.com',
41
+ mainnet: 'https://fullnode.mainnet.sui.io:443',
42
+ }
43
+ const RPC = process.env.E2E_RPC || DEFAULT_RPC[NETWORK]
44
+
45
+ const APP_URL = process.env.APP_URL || 'http://localhost:4173'
46
+ const FAUCET = process.env.E2E_FAUCET || 'http://127.0.0.1:9123'
47
+ const FEE_ADDR = process.env.E2E_FEE_ADDR || (NETWORK === 'localnet' ? '0xfee' + '0'.repeat(61) : '')
48
+ const EXPECT_FEE_MIST = BigInt(process.env.E2E_FEE_MIST || '1000000000') // 1 SUI, matches VITE_FEE_MIST default
49
+ const RESULT_TIMEOUT = Number(process.env.E2E_RESULT_TIMEOUT || '60000')
50
+
51
+ function fail(msg) {
52
+ console.error('E2E FAIL:', msg)
53
+ process.exit(1)
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Mainnet is fail-safe by default: it must never publish a real token — and spend real
58
+ // SUI — by accident or in CI. Require an explicit opt-in and abort cleanly otherwise.
59
+ // ---------------------------------------------------------------------------
60
+ if (NETWORK === 'mainnet' && process.env.E2E_MAINNET_CONFIRM !== '1') {
61
+ console.log(
62
+ 'e2e-deploy: mainnet run is gated. Set E2E_MAINNET_CONFIRM=1 to publish a REAL token on mainnet.\n' +
63
+ 'Skipping (this is the expected, safe default).',
64
+ )
65
+ process.exit(0)
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Keypair + funding. localnet mints a throwaway key from the faucet; testnet/mainnet use an
70
+ // externally funded key supplied via SUI_PRIV (never a faucet on a real network).
71
+ // ---------------------------------------------------------------------------
72
+ let keypair
73
+ if (NETWORK === 'localnet') {
74
+ keypair = new Ed25519Keypair()
75
+ } else {
76
+ if (!process.env.SUI_PRIV) {
77
+ fail(
78
+ `SUI_PRIV is required for a ${NETWORK} run (bech32 suiprivkey1… — export the funded key with ` +
79
+ `\`sui keytool export --key-identity <addr>\`). Never funds from a faucet on ${NETWORK}.`,
80
+ )
81
+ }
82
+ keypair = Ed25519Keypair.fromSecretKey(process.env.SUI_PRIV)
83
+ }
84
+ const address = keypair.toSuiAddress()
85
+
86
+ // Node-side client points at the PHYSICAL RPC; its `network` label is the app network so the
87
+ // built transaction's chain identifier matches what the wallet/app sign under.
88
+ const client = new SuiJsonRpcClient({ url: RPC, network: APP_NETWORK })
89
+
90
+ const EXPECT_FEE = Boolean(FEE_ADDR) && FEE_ADDR !== ZERO_ADDR
91
+
92
+ async function main() {
93
+ console.log(`e2e-deploy: E2E_NETWORK=${NETWORK} APP_NETWORK=${APP_NETWORK} RPC=${RPC}`)
94
+
95
+ if (NETWORK === 'localnet') {
96
+ await requestSuiFromFaucetV2({ host: FAUCET, recipient: address })
97
+ for (let i = 0; i < 25; i++) {
98
+ const b = await client.getBalance({ owner: address })
99
+ if (BigInt(b.totalBalance) > 0n) break
100
+ await new Promise((r) => setTimeout(r, 1000))
101
+ }
102
+ }
103
+
104
+ const balance = BigInt((await client.getBalance({ owner: address })).totalBalance)
105
+ console.log('sender:', address, 'balance:', balance.toString(), 'MIST')
106
+ if (balance === 0n) fail(`sender ${address} has no SUI on ${NETWORK}`)
107
+
108
+ const feeBefore = EXPECT_FEE
109
+ ? BigInt((await client.getBalance({ owner: FEE_ADDR })).totalBalance)
110
+ : 0n
111
+
112
+ // Browser resolution: explicit CHROME_PATH → a locally-installed Chrome → Playwright's
113
+ // bundled Chromium (CI installs it via `npx playwright install chromium`).
114
+ const chromePath =
115
+ process.env.CHROME_PATH ||
116
+ (existsSync('/usr/bin/google-chrome') ? '/usr/bin/google-chrome' : undefined)
117
+ const browser = await chromium.launch({ headless: true, executablePath: chromePath })
118
+ const page = await browser.newPage()
119
+ page.on('console', (m) => m.type() === 'error' && console.log('[browser error]', m.text()))
120
+ page.on('pageerror', (e) => console.log('[pageerror]', e.message))
121
+
122
+ // Node-side signer: build + sign the transaction the wallet forwards.
123
+ await page.exposeFunction('__mockSign', async (txJson) => {
124
+ const tx = Transaction.from(txJson)
125
+ const bytes = await tx.build({ client })
126
+ const { signature } = await keypair.signTransaction(bytes)
127
+ return { bytes: toBase64(bytes), signature }
128
+ })
129
+
130
+ // Inject a wallet-standard wallet before app scripts run. The account advertises the app
131
+ // network's chain so wallet-standard's required-feature/chain checks are satisfied.
132
+ await page.addInitScript(
133
+ ({ address, pubkey, chain }) => {
134
+ const account = {
135
+ address,
136
+ publicKey: new Uint8Array(pubkey),
137
+ chains: [chain],
138
+ features: ['sui:signTransaction'],
139
+ label: 'Mock',
140
+ }
141
+ const wallet = {
142
+ version: '1.0.0',
143
+ name: 'Mock Test Wallet',
144
+ icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=',
145
+ chains: [chain],
146
+ accounts: [account],
147
+ features: {
148
+ 'standard:connect': { version: '1.0.0', connect: async () => ({ accounts: [account] }) },
149
+ 'standard:events': { version: '1.0.0', on: () => () => {} },
150
+ 'sui:signTransaction': {
151
+ version: '2.0.0',
152
+ signTransaction: async (input) => {
153
+ const txJson = await input.transaction.toJSON()
154
+ return await window.__mockSign(txJson)
155
+ },
156
+ },
157
+ },
158
+ }
159
+ const callback = ({ register }) => register(wallet)
160
+ window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: callback }))
161
+ window.addEventListener('wallet-standard:app-ready', (e) => callback(e.detail))
162
+ },
163
+ { address, pubkey: Array.from(keypair.getPublicKey().toRawBytes()), chain: `sui:${APP_NETWORK}` },
164
+ )
165
+
166
+ await page.goto(APP_URL, { waitUntil: 'networkidle' })
167
+
168
+ // Connect: open the dialog, select the app network if it differs from the testnet default,
169
+ // then pick the injected wallet.
170
+ await page.getByRole('button', { name: 'Connect Wallet' }).click()
171
+ if (APP_NETWORK !== 'testnet') {
172
+ await page.locator('#dialog-network-select').selectOption(APP_NETWORK)
173
+ }
174
+ await page.getByRole('button', { name: /Mock Test Wallet/ }).click()
175
+ await page.getByText(/Mock Test Wallet ·/).waitFor({ timeout: 10000 })
176
+ console.log('wallet connected in UI')
177
+
178
+ // Fill the form.
179
+ await page.locator('#name').fill('Browser Coin')
180
+ await page.locator('#symbol').fill('BRWC')
181
+ await page.locator('#description').fill('Deployed via headless browser e2e')
182
+ await page.locator('#decimals').fill('6')
183
+ await page.locator('#package').fill('browser_coin')
184
+ await page.locator('#module').fill('browsercoin')
185
+
186
+ await page.getByRole('button', { name: /Review & deploy/ }).click()
187
+ await page.getByRole('button', { name: /Confirm & deploy/ }).click()
188
+ console.log('confirmed; deploying (real publish + confirmation)…')
189
+
190
+ // Wait for the result screen — this is the confirmation the mocked suite cannot reach.
191
+ await page.getByRole('heading', { name: /is live on/ }).waitFor({ timeout: RESULT_TIMEOUT })
192
+ const coinType = await page.locator('dd.mono').first().innerText()
193
+ console.log('UI result coinType:', coinType)
194
+
195
+ // Verify the in-browser source-package download (generatePackage + zip + SPDX license fetch).
196
+ const [download] = await Promise.all([
197
+ page.waitForEvent('download', { timeout: 30000 }),
198
+ page.getByRole('button', { name: /Download source package/ }).click(),
199
+ ])
200
+ const zipBuf = readFileSync(await download.path())
201
+ const isZip = zipBuf[0] === 0x50 && zipBuf[1] === 0x4b
202
+ console.log('download:', download.suggestedFilename(), zipBuf.length, 'bytes, zip:', isZip)
203
+ if (!isZip) fail('downloaded file is not a zip')
204
+
205
+ await browser.close()
206
+
207
+ // Independent on-chain verification.
208
+ if (!coinType.includes('::browsercoin::BROWSERCOIN')) fail(`unexpected coin type: ${coinType}`)
209
+
210
+ if (EXPECT_FEE) {
211
+ const feeAfter = BigInt((await client.getBalance({ owner: FEE_ADDR })).totalBalance)
212
+ const feeDelta = feeAfter - feeBefore
213
+ console.log('fee delivered this run:', feeDelta.toString(), 'MIST (to', FEE_ADDR + ')')
214
+ if (feeDelta !== EXPECT_FEE_MIST) fail(`expected ${EXPECT_FEE_MIST} MIST fee this run, got ${feeDelta}`)
215
+ } else {
216
+ console.log('no treasury configured — fee assertion skipped (app charges no fee)')
217
+ }
218
+
219
+ console.log(`\nDEPLOY E2E PASS ✓ (${NETWORK}: injected wallet, real publish, result panel, zip${EXPECT_FEE ? ', fee delivered' : ''})`)
220
+ }
221
+
222
+ main().catch((e) => fail(e.message))