@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.
- package/.claude/move-language-reference/SKILL.md +445 -0
- package/.claude/skills/move-language-reference/SKILL.md +445 -0
- package/.claude/skills/sui-move-bootcamp/SKILL.md +128 -0
- package/.claude/sui-move-bootcamp/SKILL.md +128 -0
- package/.editorconfig +8 -0
- package/.env.example +75 -0
- package/.env.production +7 -0
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +34 -0
- package/.github/workflows/token-deployer-e2e.yml +64 -0
- package/.oxfmtrc.json +5 -0
- package/.oxlintrc.json +10 -0
- package/.vscode/extensions.json +9 -0
- package/AGENTS.md +39 -0
- package/CLAUDE.md +159 -0
- package/LICENSE +121 -0
- package/README.md +234 -0
- package/env.d.ts +13 -0
- package/index.html +13 -0
- package/package.json +67 -0
- package/playwright.config.ts +30 -0
- package/public/CNAME +1 -0
- package/public/_headers +12 -0
- package/public/_redirects +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +5 -0
- package/public/og-image.png +0 -0
- package/public/robots.txt +3 -0
- package/public/sitemap.xml +8 -0
- package/scripts/e2e-browser.mjs +15 -0
- package/scripts/e2e-deploy.mjs +222 -0
- package/scripts/e2e-localnet.mjs +65 -0
- package/scripts/e2e-walrus-browser.mjs +115 -0
- package/scripts/gen-og-image.mjs +31 -0
- package/scripts/publish-localnet.mjs +55 -0
- package/scripts/regen-template.sh +45 -0
- package/scripts/sync-template-src.sh +36 -0
- package/scripts/verify-bytecode.mjs +49 -0
- package/src/App.vue +41 -0
- package/src/__tests__/App.spec.js +11 -0
- package/src/components/AppNotice.vue +15 -0
- package/src/components/ColorModeControl.vue +81 -0
- package/src/components/ConfigForm.vue +192 -0
- package/src/components/DeployProgress.vue +46 -0
- package/src/components/FieldHint.vue +79 -0
- package/src/components/FormField.vue +28 -0
- package/src/components/FormSelect.vue +43 -0
- package/src/components/GithubPush.vue +100 -0
- package/src/components/IconPicker.vue +410 -0
- package/src/components/LicensePicker.vue +182 -0
- package/src/components/ResultPanel.vue +88 -0
- package/src/components/ReviewPanel.vue +69 -0
- package/src/components/TermsDialog.vue +81 -0
- package/src/components/TokenDeployerView.vue +115 -0
- package/src/components/WalletBar.vue +108 -0
- package/src/components/WalrusBlobBrowser.vue +188 -0
- package/src/components/WalrusIconUpload.vue +205 -0
- package/src/composables/useAccessGate.ts +122 -0
- package/src/composables/useColorMode.ts +76 -0
- package/src/composables/useWalrusRelay.ts +21 -0
- package/src/config.ts +185 -0
- package/src/index.ts +1 -0
- package/src/lib/accessGate.ts +141 -0
- package/src/lib/buildPublishTx.ts +192 -0
- package/src/lib/deploy.ts +168 -0
- package/src/lib/deployExecutor.ts +61 -0
- package/src/lib/errors.ts +21 -0
- package/src/lib/form.ts +75 -0
- package/src/lib/generatePackage.ts +312 -0
- package/src/lib/github.ts +146 -0
- package/src/lib/licenses.ts +122 -0
- package/src/lib/readClient.ts +17 -0
- package/src/lib/template.ts +131 -0
- package/src/lib/types.ts +69 -0
- package/src/lib/validation.ts +172 -0
- package/src/lib/walrus-constants.ts +15 -0
- package/src/lib/walrus.ts +144 -0
- package/src/main.ts +154 -0
- package/src/move-template/sui_token_template.mv +0 -0
- package/src/move-template/template.ts +31 -0
- package/src/styles.css +384 -0
- package/src/template-src/files.json +10 -0
- package/test-results/.last-run.json +4 -0
- package/tests/accessGate.test.ts +108 -0
- package/tests/buildPublishTx.test.ts +243 -0
- package/tests/deploy.test.ts +81 -0
- package/tests/errors.test.ts +32 -0
- package/tests/generatePackage.test.ts +182 -0
- package/tests/github.test.ts +61 -0
- package/tests/licenses.test.ts +78 -0
- package/tests/template.test.ts +93 -0
- package/tests/templateArtifact.test.ts +61 -0
- package/tests/templateParity.test.ts +55 -0
- package/tests/useAccessGate.test.ts +101 -0
- package/tests/useWallet.test.ts +74 -0
- package/tests/useWalrusRelayGating.test.ts +30 -0
- package/tests/validation.test.ts +117 -0
- package/tests/walrusUpload.test.ts +93 -0
- package/tests/walrus_url_patch.test.ts +26 -0
- package/tsconfig.json +19 -0
- package/typedoc.json +34 -0
- package/vite.config.ts +65 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Client-side Move bytecode patching.
|
|
2
|
+
//
|
|
3
|
+
// The current on-chain binary format (v7) cannot be round-tripped by
|
|
4
|
+
// @mysten/move-bytecode-template's `update_identifiers` / `update_constants`
|
|
5
|
+
// helpers (they throw "missing field `version`"). However, `deserialize` and
|
|
6
|
+
// `serialize` DO handle v7 losslessly, so we patch the decoded JSON directly:
|
|
7
|
+
// rename identifiers in place and overwrite constant-pool entries. This is
|
|
8
|
+
// verified end-to-end against a localnet publish (see scripts/publish-localnet.mjs).
|
|
9
|
+
|
|
10
|
+
import init, { deserialize, serialize } from '@mysten/move-bytecode-template'
|
|
11
|
+
import { bcs } from '@mysten/bcs'
|
|
12
|
+
import { fromBase64 } from '@mysten/sui/utils'
|
|
13
|
+
import { SAFE_TEXT } from './validation.js'
|
|
14
|
+
import {
|
|
15
|
+
TEMPLATE_MODULE_B64,
|
|
16
|
+
TEMPLATE_IDENTIFIERS,
|
|
17
|
+
TEMPLATE_DEFAULTS,
|
|
18
|
+
} from '../move-template/template.js'
|
|
19
|
+
|
|
20
|
+
let initPromise: Promise<unknown> | null = null
|
|
21
|
+
let wasmInput: unknown = undefined
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register the wasm asset URL without loading it (browser). Call once at app
|
|
25
|
+
* startup, e.g. `configureWasm(wasmUrl)`. The actual fetch is deferred until the
|
|
26
|
+
* first patch, so the 343 kB wasm doesn't block initial page load. In Node/tests
|
|
27
|
+
* this is unnecessary — the nodejs build loads its own wasm.
|
|
28
|
+
*/
|
|
29
|
+
export function configureWasm(input: unknown): void {
|
|
30
|
+
wasmInput = input
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialise the wasm module (idempotent + cached). Lazily triggered by the
|
|
35
|
+
* patch pipeline; uses the URL registered via `configureWasm` in the browser.
|
|
36
|
+
*/
|
|
37
|
+
export function initTemplateWasm(): Promise<unknown> {
|
|
38
|
+
if (!initPromise) {
|
|
39
|
+
initPromise = Promise.resolve()
|
|
40
|
+
.then(() => (init as unknown as (i?: unknown) => unknown)(wasmInput))
|
|
41
|
+
.catch(() => undefined)
|
|
42
|
+
}
|
|
43
|
+
return initPromise
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** BCS bytes for a `vector<u8>` / `String` constant (uleb length + utf8). */
|
|
47
|
+
function vecU8(value: string): number[] {
|
|
48
|
+
return Array.from(bcs.string().serialize(value).toBytes())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sameBytes(a: number[], b: number[]): boolean {
|
|
52
|
+
return a.length === b.length && a.every((x, i) => x === b[i])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PatchParams {
|
|
56
|
+
moduleName: string
|
|
57
|
+
structName: string
|
|
58
|
+
symbol: string
|
|
59
|
+
name: string
|
|
60
|
+
description: string
|
|
61
|
+
iconUrl: string
|
|
62
|
+
decimals: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface DecodedConstant {
|
|
66
|
+
type_: 'U8' | { Vector: 'U8' } | unknown
|
|
67
|
+
data: number[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface DecodedModule {
|
|
71
|
+
identifiers: string[]
|
|
72
|
+
constant_pool: DecodedConstant[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Patch the template module with the caller's token parameters and return
|
|
77
|
+
* publishable module bytes. Pure with respect to the shipped artifact — a fresh
|
|
78
|
+
* copy is decoded each call.
|
|
79
|
+
*/
|
|
80
|
+
export async function patchTemplateModule(params: PatchParams): Promise<Uint8Array> {
|
|
81
|
+
await initTemplateWasm()
|
|
82
|
+
|
|
83
|
+
const original = fromBase64(TEMPLATE_MODULE_B64)
|
|
84
|
+
const json = deserialize(original) as unknown as DecodedModule
|
|
85
|
+
|
|
86
|
+
// 1) Rename the module + struct identifiers in place (index-preserving, so all
|
|
87
|
+
// handle references stay valid; the verifier does not require sorted ids).
|
|
88
|
+
const rename: Record<string, string> = {
|
|
89
|
+
[TEMPLATE_IDENTIFIERS.module]: params.moduleName,
|
|
90
|
+
[TEMPLATE_IDENTIFIERS.struct]: params.structName,
|
|
91
|
+
}
|
|
92
|
+
json.identifiers = json.identifiers.map((id) => rename[id] ?? id)
|
|
93
|
+
|
|
94
|
+
// 2) Overwrite the five constant-pool defaults with the requested values.
|
|
95
|
+
patchU8Constant(json, TEMPLATE_DEFAULTS.decimals, params.decimals)
|
|
96
|
+
patchVecConstant(json, TEMPLATE_DEFAULTS.symbol, params.symbol)
|
|
97
|
+
patchVecConstant(json, TEMPLATE_DEFAULTS.name, params.name)
|
|
98
|
+
patchVecConstant(json, TEMPLATE_DEFAULTS.description, params.description)
|
|
99
|
+
patchVecConstant(json, TEMPLATE_DEFAULTS.iconUrl, params.iconUrl)
|
|
100
|
+
|
|
101
|
+
return new Uint8Array(serialize(json as unknown as Parameters<typeof serialize>[0]))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function patchU8Constant(json: DecodedModule, current: number, next: number): void {
|
|
105
|
+
const entry = json.constant_pool.find(
|
|
106
|
+
(c) => c.type_ === 'U8' && c.data.length === 1 && c.data[0] === current,
|
|
107
|
+
)
|
|
108
|
+
if (!entry) throw new Error(`template U8 constant ${current} not found (artifact drift?)`)
|
|
109
|
+
if (next < 0 || next > 255 || !Number.isInteger(next)) {
|
|
110
|
+
throw new Error(`decimals must be an integer 0..255, got ${next}`)
|
|
111
|
+
}
|
|
112
|
+
entry.data = [next]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Upper bound on a patched string constant, matching the form's icon-URL cap. */
|
|
116
|
+
const MAX_VEC_CONSTANT_LEN = 512
|
|
117
|
+
|
|
118
|
+
function patchVecConstant(json: DecodedModule, current: string, next: string): void {
|
|
119
|
+
// Defence in depth: the on-chain metadata must match the generated source, so
|
|
120
|
+
// reject the same characters the form/source guard rejects, and bound the size.
|
|
121
|
+
if (!SAFE_TEXT.test(next)) {
|
|
122
|
+
throw new Error('constant contains quotes, backslashes or control characters')
|
|
123
|
+
}
|
|
124
|
+
if (next.length > MAX_VEC_CONSTANT_LEN) {
|
|
125
|
+
throw new Error(`constant exceeds ${MAX_VEC_CONSTANT_LEN} characters`)
|
|
126
|
+
}
|
|
127
|
+
const currentBytes = vecU8(current)
|
|
128
|
+
const entry = json.constant_pool.find((c) => c.type_ !== 'U8' && sameBytes(c.data, currentBytes))
|
|
129
|
+
if (!entry) throw new Error(`template constant "${current}" not found (artifact drift?)`)
|
|
130
|
+
entry.data = vecU8(next)
|
|
131
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Shared types for the token deployer.
|
|
2
|
+
|
|
3
|
+
/** A Sui network the deployer can target. */
|
|
4
|
+
export type Network = 'testnet' | 'mainnet' | 'localnet'
|
|
5
|
+
|
|
6
|
+
/** Whether the deployer keeps the ability to mint more supply after launch. */
|
|
7
|
+
export type SupplyPolicy = 'fixed' | 'mintable'
|
|
8
|
+
|
|
9
|
+
/** Whether description/icon stay updatable (MetadataCap retained) or are frozen. */
|
|
10
|
+
export type MetadataPolicy = 'updatable' | 'frozen'
|
|
11
|
+
|
|
12
|
+
/** Whether the package code can be upgraded, or is made immutable at publish. */
|
|
13
|
+
export type PackagePolicy = 'immutable' | 'upgradeable'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The full set of choices a user makes. `identifiers` are baked into the coin
|
|
17
|
+
* type and are permanent; `symbol`/`name`/`decimals` are permanent on-chain.
|
|
18
|
+
*/
|
|
19
|
+
export interface TokenConfig {
|
|
20
|
+
/** Move package name, e.g. "my_token" (snake_case identifier). */
|
|
21
|
+
packageName: string
|
|
22
|
+
/** Move module name, e.g. "mytoken" (snake_case identifier). */
|
|
23
|
+
moduleName: string
|
|
24
|
+
/** One-time-witness struct name, e.g. "MYTOKEN" (SCREAMING_SNAKE identifier). */
|
|
25
|
+
structName: string
|
|
26
|
+
|
|
27
|
+
symbol: string
|
|
28
|
+
name: string
|
|
29
|
+
description: string
|
|
30
|
+
/** Icon URL (may be a Walrus aggregator URL). Empty string allowed. */
|
|
31
|
+
iconUrl: string
|
|
32
|
+
/** 0..255; conventionally 9 on Sui. */
|
|
33
|
+
decimals: number
|
|
34
|
+
|
|
35
|
+
/** Whole-token initial supply minted to the recipient (before decimals scaling). Use 0 for none. */
|
|
36
|
+
initialSupply: bigint
|
|
37
|
+
supplyPolicy: SupplyPolicy
|
|
38
|
+
metadataPolicy: MetadataPolicy
|
|
39
|
+
packagePolicy: PackagePolicy
|
|
40
|
+
|
|
41
|
+
/** Address that receives the caps + initial supply. Defaults to the connected wallet. */
|
|
42
|
+
recipient: string
|
|
43
|
+
|
|
44
|
+
/** SPDX id (e.g. "MIT", "CC0-1.0") or "NONE" for proprietary. Affects the downloadable package only. */
|
|
45
|
+
license: string
|
|
46
|
+
/** Human-readable license name for the README (defaults to the SPDX id). */
|
|
47
|
+
licenseName?: string
|
|
48
|
+
/** Free-text descriptions used only in generated docs. */
|
|
49
|
+
packageDescription: string
|
|
50
|
+
projectName: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Result of a successful on-chain publish. */
|
|
54
|
+
export interface PublishResult {
|
|
55
|
+
network: Network
|
|
56
|
+
packageId: string
|
|
57
|
+
coinType: string
|
|
58
|
+
treasuryCapId?: string
|
|
59
|
+
metadataCapId?: string
|
|
60
|
+
currencyId?: string
|
|
61
|
+
/** Version and digest of the pending Currency<T> object — needed for finalize_registration. */
|
|
62
|
+
currencyVersion?: string
|
|
63
|
+
currencyDigest?: string
|
|
64
|
+
upgradeCapId?: string
|
|
65
|
+
digest: string
|
|
66
|
+
/** Where the trivial fee was sent, and how much (MIST). */
|
|
67
|
+
feeRecipient: string
|
|
68
|
+
feeMist: string
|
|
69
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Pure form validation. Kept separate from Vue so it is unit-testable and can be
|
|
2
|
+
// reused by the review step. Rules protect both the on-chain publish and the
|
|
3
|
+
// validity of the generated Move source.
|
|
4
|
+
|
|
5
|
+
import type { TokenConfig } from './types.js'
|
|
6
|
+
|
|
7
|
+
/** Max supply Sui `coin::mint` accepts (u64). */
|
|
8
|
+
export const MAX_U64 = 18_446_744_073_709_551_615n
|
|
9
|
+
|
|
10
|
+
/** Sui OTW rule: the witness struct is the module name, uppercased. */
|
|
11
|
+
export function deriveStructName(moduleName: string): string {
|
|
12
|
+
return moduleName.toUpperCase()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Move identifier shape: lowercase start, then lowercase/digits/underscores. */
|
|
16
|
+
export const MOVE_IDENT = /^[a-z][a-z0-9_]*$/
|
|
17
|
+
/**
|
|
18
|
+
* Printable ASCII excluding the double-quote and backslash, so values embed
|
|
19
|
+
* safely inside a Move `b"..."` byte-string literal in the downloadable source.
|
|
20
|
+
*/
|
|
21
|
+
export const SAFE_TEXT = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/
|
|
22
|
+
const SUI_ADDRESS = /^0x[0-9a-fA-F]{64}$/
|
|
23
|
+
|
|
24
|
+
/** Upper bound on package/module identifiers — bounds bytecode/const-pool size. */
|
|
25
|
+
export const MAX_IDENT_LEN = 64
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Schemes permitted for the on-chain `icon_url` metadata. `https:` covers a Walrus
|
|
29
|
+
* aggregator URL and any hosted image; `ipfs:` covers decentralised pins. This is a
|
|
30
|
+
* defence-in-depth **content** guard layered *on top of* downstream wallet/explorer URL
|
|
31
|
+
* sanitisation (not a replacement for it): it stops `javascript:`, `data:`, and insecure
|
|
32
|
+
* `http:` from ever being baked into the generated coin metadata, while renderers remain
|
|
33
|
+
* responsible for their own sanitisation. Empty (no icon) is allowed by the caller.
|
|
34
|
+
*/
|
|
35
|
+
export const ICON_URL_ALLOWED_SCHEMES = ['https://', 'ipfs://'] as const
|
|
36
|
+
|
|
37
|
+
/** True if `url` begins with an allowed icon scheme (case-insensitive). */
|
|
38
|
+
export function hasAllowedIconScheme(url: string): boolean {
|
|
39
|
+
const u = url.trim().toLowerCase()
|
|
40
|
+
return ICON_URL_ALLOWED_SCHEMES.some((s) => u.startsWith(s))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Move reserved words. A package or module named after one of these compiles
|
|
45
|
+
* incorrectly or fails, so they are rejected up front. Lowercase-compared, which
|
|
46
|
+
* also covers the derived (uppercased) OTW struct name.
|
|
47
|
+
*/
|
|
48
|
+
export const MOVE_RESERVED_WORDS: ReadonlySet<string> = new Set([
|
|
49
|
+
'abort',
|
|
50
|
+
'acquires',
|
|
51
|
+
'as',
|
|
52
|
+
'break',
|
|
53
|
+
'const',
|
|
54
|
+
'continue',
|
|
55
|
+
'copy',
|
|
56
|
+
'else',
|
|
57
|
+
'entry',
|
|
58
|
+
'enum',
|
|
59
|
+
'false',
|
|
60
|
+
'for',
|
|
61
|
+
'friend',
|
|
62
|
+
'fun',
|
|
63
|
+
'has',
|
|
64
|
+
'if',
|
|
65
|
+
'in',
|
|
66
|
+
'invariant',
|
|
67
|
+
'let',
|
|
68
|
+
'loop',
|
|
69
|
+
'macro',
|
|
70
|
+
'match',
|
|
71
|
+
'module',
|
|
72
|
+
'move',
|
|
73
|
+
'mut',
|
|
74
|
+
'native',
|
|
75
|
+
'package',
|
|
76
|
+
'phantom',
|
|
77
|
+
'public',
|
|
78
|
+
'return',
|
|
79
|
+
'script',
|
|
80
|
+
'spec',
|
|
81
|
+
'struct',
|
|
82
|
+
'true',
|
|
83
|
+
'type',
|
|
84
|
+
'use',
|
|
85
|
+
'while',
|
|
86
|
+
'key',
|
|
87
|
+
'store',
|
|
88
|
+
'drop',
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
/** Validate one Move identifier field; returns an error message or null. */
|
|
92
|
+
export function validateIdentifier(value: string): string | null {
|
|
93
|
+
if (!value) return 'Required'
|
|
94
|
+
if (value.length > MAX_IDENT_LEN) return `${MAX_IDENT_LEN} characters maximum`
|
|
95
|
+
if (!/^[a-z]/.test(value)) return 'Must start with a lowercase letter'
|
|
96
|
+
if (!MOVE_IDENT.test(value)) return 'Lowercase letters, digits and underscores only'
|
|
97
|
+
if (MOVE_RESERVED_WORDS.has(value)) return `"${value}" is a reserved keyword`
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type FormErrors = Partial<Record<keyof TokenConfig, string>>
|
|
102
|
+
|
|
103
|
+
export interface ValidatableForm {
|
|
104
|
+
packageName: string
|
|
105
|
+
moduleName: string
|
|
106
|
+
symbol: string
|
|
107
|
+
name: string
|
|
108
|
+
description: string
|
|
109
|
+
iconUrl: string
|
|
110
|
+
decimals: number | string
|
|
111
|
+
initialSupply: string
|
|
112
|
+
recipient: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Validate the user-entered fields. Returns a map of field -> message. */
|
|
116
|
+
export function validateForm(form: ValidatableForm): FormErrors {
|
|
117
|
+
const errors: FormErrors = {}
|
|
118
|
+
|
|
119
|
+
const packageNameError = validateIdentifier(form.packageName)
|
|
120
|
+
if (packageNameError) errors.packageName = packageNameError
|
|
121
|
+
const moduleNameError = validateIdentifier(form.moduleName)
|
|
122
|
+
if (moduleNameError) errors.moduleName = moduleNameError
|
|
123
|
+
|
|
124
|
+
if (!form.symbol.trim()) errors.symbol = 'Required'
|
|
125
|
+
else if (form.symbol.length > 32) errors.symbol = 'Maximum 32 characters'
|
|
126
|
+
else if (!SAFE_TEXT.test(form.symbol)) errors.symbol = 'Double quotes and backslashes are not supported'
|
|
127
|
+
|
|
128
|
+
if (!form.name.trim()) errors.name = 'Required'
|
|
129
|
+
else if (form.name.length > 64) errors.name = 'Maximum 64 characters'
|
|
130
|
+
else if (!SAFE_TEXT.test(form.name)) errors.name = 'Double quotes and backslashes are not supported'
|
|
131
|
+
|
|
132
|
+
if (form.description.length > 256) errors.description = 'Maximum 256 characters'
|
|
133
|
+
else if (!SAFE_TEXT.test(form.description)) errors.description = 'Double quotes and backslashes are not supported'
|
|
134
|
+
|
|
135
|
+
if (form.iconUrl) {
|
|
136
|
+
if (form.iconUrl.length > 512) errors.iconUrl = 'Maximum 512 characters'
|
|
137
|
+
else if (!SAFE_TEXT.test(form.iconUrl)) errors.iconUrl = 'Double quotes and backslashes are not supported'
|
|
138
|
+
else if (!hasAllowedIconScheme(form.iconUrl)) errors.iconUrl = 'Use an https:// or ipfs:// URL'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const decimals = Number(form.decimals)
|
|
142
|
+
if (!Number.isInteger(decimals)) errors.decimals = 'Enter a whole number'
|
|
143
|
+
else if (decimals < 0) errors.decimals = 'Must be 0 or greater'
|
|
144
|
+
else if (decimals > 18) errors.decimals = 'Maximum is 18 (9 is typical on Sui)'
|
|
145
|
+
|
|
146
|
+
const supply = parseSupply(form.initialSupply)
|
|
147
|
+
if (supply === null) {
|
|
148
|
+
errors.initialSupply = 'Enter a whole number, or leave blank for none'
|
|
149
|
+
} else if (Number.isInteger(decimals) && decimals >= 0 && decimals <= 18) {
|
|
150
|
+
if (supply * 10n ** BigInt(decimals) > MAX_U64) {
|
|
151
|
+
errors.initialSupply = 'Supply exceeds the maximum for this decimal precision'
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (form.recipient && !SUI_ADDRESS.test(form.recipient)) {
|
|
156
|
+
errors.recipient = 'Enter a full Sui address (0x followed by 64 hex digits)'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return errors
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse the supply text into a non-negative bigint, or null if invalid. */
|
|
163
|
+
export function parseSupply(input: string): bigint | null {
|
|
164
|
+
const s = input.trim()
|
|
165
|
+
if (s === '') return 0n
|
|
166
|
+
if (!/^\d+$/.test(s)) return null
|
|
167
|
+
return BigInt(s)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function isValid(errors: FormErrors): boolean {
|
|
171
|
+
return Object.keys(errors).length === 0
|
|
172
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Lightweight Walrus constants with NO heavy imports, so both the (lazily loaded)
|
|
2
|
+
// `lib/walrus.ts` client and the eagerly rendered `WalrusIconUpload.vue` component
|
|
3
|
+
// can share a single source of truth without pulling `@mysten/walrus` into the
|
|
4
|
+
// component's chunk.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Storage duration (in Walrus epochs) reserved for an uploaded icon.
|
|
8
|
+
*
|
|
9
|
+
* Capped at Walrus's `max_epochs_ahead` (53 on testnet/mainnet — the maximum a
|
|
10
|
+
* single `reserve_space` reservation accepts; a larger value aborts on-chain with
|
|
11
|
+
* MoveAbort code 2). 53 epochs ≈ ~2 years at the ~2-week epoch cadence. Longer
|
|
12
|
+
* retention requires periodically extending the blob before expiry (there is no
|
|
13
|
+
* keeper — this is a manual/operator step).
|
|
14
|
+
*/
|
|
15
|
+
export const ICON_EPOCHS = 53
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Minimal Walrus client for icon uploads. Mirrors the canonical helpers in the
|
|
2
|
+
// `@meddleware/sui-walrus` package (createWalrusClient / createBlobUploadFlow /
|
|
3
|
+
// walrusBlobUrl / fetchOwnedWalrusBlobs) but is kept self-contained here so it
|
|
4
|
+
// resolves cleanly in the app's Vite build and preserves the lazy-load boundary
|
|
5
|
+
// around `@mysten/walrus`. Once `@meddleware/sui-walrus` is published this file
|
|
6
|
+
// should be replaced by imports from it (injecting the app's relay host + max tip).
|
|
7
|
+
//
|
|
8
|
+
// Icons are stored as RAW blobs (not quilts) so `GET /v1/blobs/<blobId>` returns
|
|
9
|
+
// the exact image bytes — directly renderable by wallets/explorers. An upload
|
|
10
|
+
// relay is REQUIRED (direct-to-storage-node writes fail from browsers). Verified
|
|
11
|
+
// end-to-end on testnet (scripts/walrus-upload verification).
|
|
12
|
+
|
|
13
|
+
import { SuiGrpcClient } from '@mysten/sui/grpc'
|
|
14
|
+
import { walrus, blobIdFromInt } from '@mysten/walrus'
|
|
15
|
+
import type { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
|
|
16
|
+
import { WALRUS_RELAY_HOSTS, WALRUS_MAX_TIP_MIST, WALRUS_RPC_URLS } from '../config.js'
|
|
17
|
+
|
|
18
|
+
export { ICON_EPOCHS } from './walrus-constants.js'
|
|
19
|
+
|
|
20
|
+
export type WalrusNetwork = 'testnet' | 'mainnet'
|
|
21
|
+
|
|
22
|
+
const AGGREGATOR_HOSTS: Record<WalrusNetwork, string> = {
|
|
23
|
+
testnet: 'https://aggregator.walrus-testnet.walrus.space',
|
|
24
|
+
mainnet: 'https://aggregator.walrus-mainnet.walrus.space',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Public URL that serves the raw blob bytes. */
|
|
28
|
+
export function walrusBlobUrl(network: WalrusNetwork, blobId: string): string {
|
|
29
|
+
return `${AGGREGATOR_HOSTS[network]}/v1/blobs/${blobId}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Options for {@link createWalrusClient}. */
|
|
33
|
+
export interface CreateWalrusClientOptions {
|
|
34
|
+
/** Browser WASM URL (Vite `?url` import). */
|
|
35
|
+
wasmUrl?: string
|
|
36
|
+
/**
|
|
37
|
+
* Upload relay host. Defaults to the configured operator/public relay for the
|
|
38
|
+
* network ({@link WALRUS_RELAY_HOSTS}); pass a value to honour a user-supplied URL.
|
|
39
|
+
*/
|
|
40
|
+
uploadRelayHost?: string
|
|
41
|
+
/**
|
|
42
|
+
* Maximum tip (MIST) the client will pay. Defaults to {@link WALRUS_MAX_TIP_MIST}.
|
|
43
|
+
* The SDK throws if the relay's tip exceeds this, so keep it above the relay tip.
|
|
44
|
+
*/
|
|
45
|
+
uploadRelayMaxTipMist?: bigint
|
|
46
|
+
/**
|
|
47
|
+
* Access-proof token for an NFT-gated operator relay (base64 JSON from the access gate).
|
|
48
|
+
* Sent as the relay `Authorization: Bearer` header; the `nft-gate` gateway verifies it
|
|
49
|
+
* before proxying to the relay. Omit for the public relay or an ungated operator relay.
|
|
50
|
+
*/
|
|
51
|
+
uploadRelayAuthToken?: string
|
|
52
|
+
/**
|
|
53
|
+
* gRPC fullnode URL for the underlying `SuiGrpcClient`. Defaults to
|
|
54
|
+
* {@link WALRUS_RPC_URLS} for the network (operator-overridable via `VITE_WALRUS_RPC_*`).
|
|
55
|
+
* NOTE: must be a **gRPC** endpoint — not a JSON-RPC URL (see `WALRUS_RPC_URLS`).
|
|
56
|
+
*/
|
|
57
|
+
rpcUrl?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A Walrus-extended gRPC client configured with an upload relay + tip guard.
|
|
62
|
+
*
|
|
63
|
+
* The relay's tip address/amount is fetched by the SDK from the relay's
|
|
64
|
+
* `/v1/tip-config`; only the `max` (client-side ceiling) is set here. The
|
|
65
|
+
* default relay is the operator relay when `VITE_WALRUS_RELAY_*` is set,
|
|
66
|
+
* otherwise the public Mysten relay (which earns the operator nothing).
|
|
67
|
+
*/
|
|
68
|
+
export function createWalrusClient(
|
|
69
|
+
network: WalrusNetwork,
|
|
70
|
+
wasmUrlOrOptions?: string | CreateWalrusClientOptions,
|
|
71
|
+
) {
|
|
72
|
+
// Back-compat: a bare string is treated as the WASM URL.
|
|
73
|
+
const opts: CreateWalrusClientOptions =
|
|
74
|
+
typeof wasmUrlOrOptions === 'string' ? { wasmUrl: wasmUrlOrOptions } : (wasmUrlOrOptions ?? {})
|
|
75
|
+
const host = opts.uploadRelayHost ?? WALRUS_RELAY_HOSTS[network]
|
|
76
|
+
const maxTip = opts.uploadRelayMaxTipMist ?? WALRUS_MAX_TIP_MIST
|
|
77
|
+
const baseUrl = opts.rpcUrl ?? WALRUS_RPC_URLS[network]
|
|
78
|
+
return new SuiGrpcClient({ network, baseUrl }).$extend(
|
|
79
|
+
walrus({
|
|
80
|
+
...(opts.wasmUrl ? { wasmUrl: opts.wasmUrl } : {}),
|
|
81
|
+
uploadRelay: {
|
|
82
|
+
host,
|
|
83
|
+
sendTip: { max: Number(maxTip) },
|
|
84
|
+
...(opts.uploadRelayAuthToken
|
|
85
|
+
? { headers: { Authorization: `Bearer ${opts.uploadRelayAuthToken}` } }
|
|
86
|
+
: {}),
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Raw-blob write flow: encode -> register(tx) -> upload -> certify(tx) -> getBlob. */
|
|
93
|
+
export function createBlobUploadFlow(
|
|
94
|
+
client: ReturnType<typeof createWalrusClient>,
|
|
95
|
+
bytes: Uint8Array,
|
|
96
|
+
) {
|
|
97
|
+
return client.walrus.writeBlobFlow({ blob: bytes })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A Walrus blob object owned by a Sui address. */
|
|
101
|
+
export interface OwnedBlob {
|
|
102
|
+
objectId: string
|
|
103
|
+
/** Aggregator-URL-compatible blob ID string. */
|
|
104
|
+
blobId: string
|
|
105
|
+
size: number
|
|
106
|
+
endEpoch: number
|
|
107
|
+
certified: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return all Walrus blobs owned by `owner`. Uses `getBlobType()` for dynamic
|
|
112
|
+
* package resolution so no hardcoded addresses are needed.
|
|
113
|
+
*/
|
|
114
|
+
export async function fetchOwnedWalrusBlobs(
|
|
115
|
+
suiClient: SuiJsonRpcClient,
|
|
116
|
+
walrusClient: ReturnType<typeof createWalrusClient>,
|
|
117
|
+
owner: string,
|
|
118
|
+
): Promise<OwnedBlob[]> {
|
|
119
|
+
const blobType = await walrusClient.walrus.getBlobType()
|
|
120
|
+
const { data } = await suiClient.getOwnedObjects({
|
|
121
|
+
owner,
|
|
122
|
+
filter: { StructType: blobType },
|
|
123
|
+
options: { showContent: true },
|
|
124
|
+
})
|
|
125
|
+
const blobs: OwnedBlob[] = []
|
|
126
|
+
for (const item of data) {
|
|
127
|
+
if (!item.data) continue
|
|
128
|
+
const fields = (item.data.content as { fields?: Record<string, unknown> } | undefined)?.fields
|
|
129
|
+
if (!fields) continue
|
|
130
|
+
const storage = fields.storage as { fields?: { end_epoch?: unknown } } | undefined
|
|
131
|
+
try {
|
|
132
|
+
blobs.push({
|
|
133
|
+
objectId: item.data.objectId,
|
|
134
|
+
blobId: blobIdFromInt(BigInt(fields.blob_id as string)),
|
|
135
|
+
size: Number(fields.size),
|
|
136
|
+
endEpoch: Number(storage?.fields?.end_epoch ?? 0),
|
|
137
|
+
certified: fields.certified_epoch !== null && fields.certified_epoch !== undefined,
|
|
138
|
+
})
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip blobs whose fields cannot be parsed.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return blobs
|
|
144
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createApp } from 'vue'
|
|
2
|
+
import App from './App.vue'
|
|
3
|
+
import './styles.css'
|
|
4
|
+
import { configureWasm } from './lib/template.js'
|
|
5
|
+
import { useColorMode } from './composables/useColorMode.js'
|
|
6
|
+
|
|
7
|
+
// Apply the colour mode before mount so there is no theme flash. Defaults to
|
|
8
|
+
// `system` (follows the OS) while enabling the in-app light/dark/system control.
|
|
9
|
+
useColorMode('system')
|
|
10
|
+
|
|
11
|
+
// Vite serves the wasm as an asset URL; wasm-bindgen's init loads it on demand.
|
|
12
|
+
import wasmUrl from '@mysten/move-bytecode-template/web/move_bytecode_template_bg.wasm?url'
|
|
13
|
+
|
|
14
|
+
// Register the URL only; the 343 kB wasm is fetched lazily at first deploy so it
|
|
15
|
+
// doesn't block initial page load.
|
|
16
|
+
configureWasm(wasmUrl)
|
|
17
|
+
|
|
18
|
+
// E2E-only: register mock wallet and expose getSuiClient for headless tests.
|
|
19
|
+
// Wrapped in async IIFE to ensure wallet is registered BEFORE app mounts (timing critical).
|
|
20
|
+
// Tree-shaking removes this entire block from production when VITE_E2E !== '1'.
|
|
21
|
+
;(async () => {
|
|
22
|
+
if (import.meta.env.VITE_E2E === '1') {
|
|
23
|
+
const { getWallets } = await import('@mysten/wallet-standard')
|
|
24
|
+
const { getReadClient: getSuiClient } = await import('./lib/readClient.js')
|
|
25
|
+
|
|
26
|
+
// Mock fetch globally for RPC requests so Cypress intercepts are bypassed entirely
|
|
27
|
+
const originalFetch = globalThis.fetch
|
|
28
|
+
const publishDigest = '0x' + 'aa'.repeat(32)
|
|
29
|
+
const packageId = '0x' + 'bb'.repeat(32)
|
|
30
|
+
const treasureCapId = '0x' + 'cc'.repeat(32)
|
|
31
|
+
const metadataCapId = '0x' + 'dd'.repeat(32)
|
|
32
|
+
|
|
33
|
+
const mockRpcResponse = {
|
|
34
|
+
digest: publishDigest,
|
|
35
|
+
effects: {
|
|
36
|
+
status: { status: 'success' },
|
|
37
|
+
gasUsed: { computationCost: '1000', storageCost: '2000', storageRebate: '0' },
|
|
38
|
+
},
|
|
39
|
+
objectChanges: [
|
|
40
|
+
{ type: 'published', packageId },
|
|
41
|
+
{
|
|
42
|
+
type: 'created',
|
|
43
|
+
objectType: `0x2::coin::TreasuryCap<${packageId}::mytoken::MYTOKEN>`,
|
|
44
|
+
objectId: treasureCapId,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'created',
|
|
48
|
+
objectType: `0x2::coin_registry::MetadataCap<${packageId}::mytoken::MYTOKEN>`,
|
|
49
|
+
objectId: metadataCapId,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
events: [],
|
|
53
|
+
transaction: null,
|
|
54
|
+
balanceChanges: null,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
58
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
59
|
+
const method = init?.method || 'GET'
|
|
60
|
+
const isRpcCall = (url.includes('fullnode') || url.includes('rpc')) && method === 'POST'
|
|
61
|
+
|
|
62
|
+
if (isRpcCall) {
|
|
63
|
+
const body = init?.body ? JSON.parse(String(init.body)) : {}
|
|
64
|
+
const rpcMethod = body.method
|
|
65
|
+
|
|
66
|
+
if (import.meta.env.VITE_E2E === '1') {
|
|
67
|
+
console.log('[E2E Fetch Mock]', rpcMethod, 'URL:', url)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result =
|
|
71
|
+
rpcMethod === 'sui_executeTransactionBlock' || rpcMethod === 'sui_getTransactionBlock'
|
|
72
|
+
? mockRpcResponse
|
|
73
|
+
: rpcMethod === 'suix_getCoins' || rpcMethod === 'suix_getBalance'
|
|
74
|
+
? { data: [], nextCursor: null, hasNextPage: false }
|
|
75
|
+
: null
|
|
76
|
+
|
|
77
|
+
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result }), {
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: { 'content-type': 'application/json' },
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return originalFetch(input, init)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const E2E_ADDR = '0x' + 'a'.repeat(64)
|
|
87
|
+
const E2E_PUB_KEY = new Uint8Array(32)
|
|
88
|
+
|
|
89
|
+
const mockWallet = {
|
|
90
|
+
name: 'Test Wallet',
|
|
91
|
+
version: '1.0.0',
|
|
92
|
+
icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
93
|
+
chains: ['sui:testnet', 'sui:mainnet'],
|
|
94
|
+
accounts: [],
|
|
95
|
+
features: {
|
|
96
|
+
'standard:connect': {
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
connect: async () => ({
|
|
99
|
+
accounts: [
|
|
100
|
+
{
|
|
101
|
+
address: E2E_ADDR,
|
|
102
|
+
publicKey: E2E_PUB_KEY,
|
|
103
|
+
chains: ['sui:testnet'],
|
|
104
|
+
features: [],
|
|
105
|
+
label: 'Test',
|
|
106
|
+
icon: undefined,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
'standard:events': {
|
|
112
|
+
version: '1.0.0',
|
|
113
|
+
// The app subscribes to the global registry's register/unregister events,
|
|
114
|
+
// not to per-wallet events, so a no-op listener returning a no-op
|
|
115
|
+
// unsubscribe satisfies the wallet-standard required-feature filter.
|
|
116
|
+
on: () => () => {},
|
|
117
|
+
},
|
|
118
|
+
'standard:disconnect': {
|
|
119
|
+
version: '1.0.0',
|
|
120
|
+
disconnect: async () => undefined,
|
|
121
|
+
},
|
|
122
|
+
'sui:signTransaction': {
|
|
123
|
+
version: '2.0.0',
|
|
124
|
+
signTransaction: async () => ({
|
|
125
|
+
bytes: 'bW9jaw==',
|
|
126
|
+
signature: 'bW9jaw==',
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const api = getWallets()
|
|
133
|
+
let unregister: (() => void) | undefined
|
|
134
|
+
const w = window as unknown as Record<string, unknown>
|
|
135
|
+
|
|
136
|
+
// Register mock wallet synchronously before app mounts so it's available immediately
|
|
137
|
+
unregister = api.register(mockWallet as never)
|
|
138
|
+
w.__e2eAddress = E2E_ADDR
|
|
139
|
+
|
|
140
|
+
// Expose register/unregister for test control (e.g., disconnect test)
|
|
141
|
+
w.__registerMockWallet = () => {
|
|
142
|
+
if (!unregister) unregister = api.register(mockWallet as never)
|
|
143
|
+
}
|
|
144
|
+
w.__unregisterMockWallet = () => {
|
|
145
|
+
unregister?.()
|
|
146
|
+
unregister = undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Expose getSuiClient for tests to build transactions
|
|
150
|
+
w.__getSuiClient = getSuiClient
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
createApp(App).mount('#app')
|
|
154
|
+
})()
|
|
Binary file
|