@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
@@ -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
+ }
@@ -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
+ })()