@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,78 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import {
3
+ fetchLicenseList,
4
+ fetchLicenseText,
5
+ NO_LICENSE,
6
+ __resetLicenseCaches,
7
+ } from '../src/lib/licenses.js'
8
+
9
+ const LIST = {
10
+ licenses: [
11
+ { licenseId: 'MIT', name: 'MIT License', isOsiApproved: true, isDeprecatedLicenseId: false },
12
+ { licenseId: 'CC0-1.0', name: 'Creative Commons Zero v1.0 Universal', isOsiApproved: false, isDeprecatedLicenseId: false },
13
+ { licenseId: 'GPL-1.0', name: 'GNU GPL v1.0 (deprecated)', isOsiApproved: false, isDeprecatedLicenseId: true },
14
+ ],
15
+ }
16
+
17
+ function mockFetch(bodyByUrl: Record<string, { ok?: boolean; json?: unknown; text?: string; status?: number }>) {
18
+ return vi.fn<(url: string) => Promise<Response>>(async (url: string): Promise<Response> => {
19
+ const entry = bodyByUrl[url as string] ?? bodyByUrl['*']
20
+ return {
21
+ ok: entry?.ok ?? true,
22
+ status: entry?.status ?? 200,
23
+ json: async () => entry?.json,
24
+ text: async () => entry?.text ?? '',
25
+ } as Response
26
+ }) as unknown as typeof fetch
27
+ }
28
+
29
+ const LIST_URL = 'https://raw.githubusercontent.com/spdx/license-list-data/main/json/licenses.json'
30
+
31
+ beforeEach(() => __resetLicenseCaches())
32
+
33
+ describe('fetchLicenseList', () => {
34
+ it('filters deprecated, sorts, includes NONE, and surfaces popular first', async () => {
35
+ const f = mockFetch({ [LIST_URL]: { json: LIST } })
36
+ const cat = await fetchLicenseList(f)
37
+
38
+ expect(cat.all[0]).toBe(NO_LICENSE)
39
+ const ids = cat.all.map((l) => l.id)
40
+ expect(ids).toContain('MIT')
41
+ expect(ids).toContain('CC0-1.0')
42
+ expect(ids).not.toContain('GPL-1.0') // deprecated dropped
43
+
44
+ // popular ordering: CC0 before MIT
45
+ const popIds = cat.popular.map((l) => l.id)
46
+ expect(popIds.indexOf('CC0-1.0')).toBeLessThan(popIds.indexOf('MIT'))
47
+ })
48
+
49
+ it('caches the list (one network call across calls)', async () => {
50
+ const f = mockFetch({ [LIST_URL]: { json: LIST } })
51
+ await fetchLicenseList(f)
52
+ await fetchLicenseList(f)
53
+ expect((f as unknown as { mock: { calls: unknown[] } }).mock.calls.length).toBe(1)
54
+ })
55
+ })
56
+
57
+ describe('fetchLicenseText', () => {
58
+ it('fetches and caches license text', async () => {
59
+ const url = 'https://raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt'
60
+ const f = mockFetch({ [url]: { text: 'MIT LICENSE BODY' } })
61
+ expect(await fetchLicenseText('MIT', f)).toBe('MIT LICENSE BODY')
62
+ await fetchLicenseText('MIT', f)
63
+ expect((f as unknown as { mock: { calls: unknown[] } }).mock.calls.length).toBe(1)
64
+ })
65
+
66
+ it('returns null (no fetch) for the proprietary choice', async () => {
67
+ const f = mockFetch({})
68
+ expect(await fetchLicenseText('NONE', f)).toBeNull()
69
+ expect(await fetchLicenseText('', f)).toBeNull()
70
+ expect((f as unknown as { mock: { calls: unknown[] } }).mock.calls.length).toBe(0)
71
+ })
72
+
73
+ it('throws on a failed fetch', async () => {
74
+ const url = 'https://raw.githubusercontent.com/spdx/license-list-data/main/text/BOGUS.txt'
75
+ const f = mockFetch({ [url]: { ok: false, status: 404 } })
76
+ await expect(fetchLicenseText('BOGUS', f)).rejects.toThrow(/BOGUS/)
77
+ })
78
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import init, { deserialize } from '@mysten/move-bytecode-template'
3
+ import { patchTemplateModule } from '../src/lib/template.js'
4
+ import { TEMPLATE_DEFAULTS } from '../src/move-template/template.js'
5
+
6
+ const asStr = (data: number[]) => Buffer.from(data.slice(1)).toString('utf8')
7
+
8
+ interface Decoded {
9
+ identifiers: string[]
10
+ constant_pool: { type_: unknown; data: number[] }[]
11
+ }
12
+
13
+ describe('patchTemplateModule', () => {
14
+ it('renames identifiers and patches all five constants, producing a valid module', async () => {
15
+ await (init as unknown as () => Promise<unknown>)()
16
+
17
+ const bytes = await patchTemplateModule({
18
+ moduleName: 'mycoin',
19
+ structName: 'MYCOIN',
20
+ symbol: 'MYC',
21
+ name: 'My Coin',
22
+ description: 'A great coin',
23
+ iconUrl: 'https://example.com/icon.svg',
24
+ decimals: 6,
25
+ })
26
+
27
+ // valid Move module magic
28
+ expect(Buffer.from(bytes.slice(0, 4)).toString('hex')).toBe('a11ceb0b')
29
+
30
+ const back = deserialize(bytes) as unknown as Decoded
31
+
32
+ // identifiers renamed; template identifiers gone
33
+ expect(back.identifiers).toContain('mycoin')
34
+ expect(back.identifiers).toContain('MYCOIN')
35
+ expect(back.identifiers).not.toContain('sui_token_template')
36
+ expect(back.identifiers).not.toContain('SUI_TOKEN_TEMPLATE')
37
+
38
+ // constants patched
39
+ expect(back.constant_pool.some((c) => c.type_ === 'U8' && c.data[0] === 6)).toBe(true)
40
+ for (const v of ['MYC', 'My Coin', 'A great coin', 'https://example.com/icon.svg']) {
41
+ expect(back.constant_pool.some((c) => c.type_ !== 'U8' && asStr(c.data) === v)).toBe(true)
42
+ }
43
+ // no template defaults remain
44
+ for (const def of [
45
+ TEMPLATE_DEFAULTS.symbol,
46
+ TEMPLATE_DEFAULTS.name,
47
+ TEMPLATE_DEFAULTS.description,
48
+ TEMPLATE_DEFAULTS.iconUrl,
49
+ ]) {
50
+ expect(back.constant_pool.some((c) => c.type_ !== 'U8' && asStr(c.data) === def)).toBe(false)
51
+ }
52
+ })
53
+
54
+ it('supports an empty icon URL', async () => {
55
+ const bytes = await patchTemplateModule({
56
+ moduleName: 'noicon',
57
+ structName: 'NOICON',
58
+ symbol: 'NIC',
59
+ name: 'No Icon',
60
+ description: 'desc',
61
+ iconUrl: '',
62
+ decimals: 9,
63
+ })
64
+ const back = deserialize(bytes) as unknown as Decoded
65
+ // empty string encodes as a single 0x00 length byte
66
+ expect(back.constant_pool.some((c) => c.type_ !== 'U8' && c.data.length === 1 && c.data[0] === 0)).toBe(true)
67
+ })
68
+
69
+ it('rejects out-of-range decimals', async () => {
70
+ await expect(
71
+ patchTemplateModule({
72
+ moduleName: 'x', structName: 'X', symbol: 'X', name: 'X', description: 'X', iconUrl: '', decimals: 300,
73
+ }),
74
+ ).rejects.toThrow(/decimals/)
75
+ })
76
+
77
+ it('rejects a string constant with control chars or backslashes (defence in depth)', async () => {
78
+ await expect(
79
+ patchTemplateModule({
80
+ moduleName: 'x', structName: 'X', symbol: 'E\nVIL', name: 'X', description: 'X', iconUrl: '', decimals: 9,
81
+ }),
82
+ ).rejects.toThrow(/control characters|quotes|backslashes/)
83
+
84
+ await expect(
85
+ patchTemplateModule({
86
+ moduleName: 'x', structName: 'X', symbol: 'X', name: 'X', description: 'X',
87
+ iconUrl: 'x'.repeat(600), decimals: 9,
88
+ }),
89
+ ).rejects.toThrow(/exceeds/)
90
+ })
91
+ })
92
+
93
+ // Temporary test to check Walrus blob URL patching
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import init, { deserialize } from '@mysten/move-bytecode-template'
3
+ import { fromBase64 } from '@mysten/sui/utils'
4
+ import {
5
+ TEMPLATE_MODULE_B64,
6
+ TEMPLATE_IDENTIFIERS,
7
+ TEMPLATE_DEFAULTS,
8
+ } from '../src/move-template/template.js'
9
+
10
+ // Provenance guard: the checked-in bytecode (.mv, as base64) and the hand-kept
11
+ // TEMPLATE_IDENTIFIERS / TEMPLATE_DEFAULTS in move-template/template.ts must stay
12
+ // consistent. If regen-template.sh is run but the DEFAULTS aren't updated (or vice
13
+ // versa), the deployer's patch would silently fail to match — this test catches
14
+ // that drift in CI instead of at deploy time.
15
+
16
+ interface Decoded {
17
+ identifiers: string[]
18
+ constant_pool: { type_: unknown; data: number[] }[]
19
+ }
20
+
21
+ // A BCS-encoded String/vector<u8> is a uleb length prefix + utf8 bytes; for these
22
+ // short defaults the prefix is a single byte, so slice(1) recovers the text.
23
+ const asStr = (data: number[]) => Buffer.from(data.slice(1)).toString('utf8')
24
+
25
+ describe('template artifact provenance', () => {
26
+ let decoded: Decoded
27
+
28
+ beforeAll(async () => {
29
+ await (init as unknown as () => Promise<unknown>)()
30
+ decoded = deserialize(fromBase64(TEMPLATE_MODULE_B64)) as unknown as Decoded
31
+ })
32
+
33
+ it('is a valid v7 Move module (magic a11ceb0b)', () => {
34
+ expect(Buffer.from(fromBase64(TEMPLATE_MODULE_B64).slice(0, 4)).toString('hex')).toBe('a11ceb0b')
35
+ })
36
+
37
+ it('contains the declared template identifiers', () => {
38
+ expect(decoded.identifiers).toContain(TEMPLATE_IDENTIFIERS.module)
39
+ expect(decoded.identifiers).toContain(TEMPLATE_IDENTIFIERS.struct)
40
+ })
41
+
42
+ it('contains the declared decimals default as a U8 constant', () => {
43
+ expect(
44
+ decoded.constant_pool.some((c) => c.type_ === 'U8' && c.data[0] === TEMPLATE_DEFAULTS.decimals),
45
+ ).toBe(true)
46
+ })
47
+
48
+ it('contains each declared string default as a distinct vector constant', () => {
49
+ for (const def of [
50
+ TEMPLATE_DEFAULTS.symbol,
51
+ TEMPLATE_DEFAULTS.name,
52
+ TEMPLATE_DEFAULTS.description,
53
+ TEMPLATE_DEFAULTS.iconUrl,
54
+ ]) {
55
+ expect(
56
+ decoded.constant_pool.some((c) => c.type_ !== 'U8' && asStr(c.data) === def),
57
+ `default "${def}" not found in the shipped bytecode`,
58
+ ).toBe(true)
59
+ }
60
+ })
61
+ })
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, existsSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { createRequire } from 'node:module'
5
+ import templateFiles from '../src/template-src/files.json'
6
+
7
+ // Single-source-of-truth guard. `src/template-src/files.json` is generated from
8
+ // the canonical sui-token-template by scripts/sync-template-src.sh. This test
9
+ // fails if the two drift — i.e. the template changed but the sync script was not
10
+ // re-run — so the downloadable package can never silently diverge from the CLI
11
+ // generator (and, via the shared source, the on-chain bytecode).
12
+
13
+ const files = templateFiles as Record<string, string>
14
+
15
+ // The canonical template ships as the published @meddleware/sui-token-template
16
+ // package (resolved from node_modules); SUI_TOKEN_TEMPLATE_DIR overrides with a
17
+ // local checkout. When neither resolves (e.g. before the package is published),
18
+ // the per-file parity assertions skip — the app still ships the vendored
19
+ // files.json + .mv, so this is a maintainer-time check only.
20
+ function resolveTemplateDir(): string | null {
21
+ if (process.env.SUI_TOKEN_TEMPLATE_DIR) return process.env.SUI_TOKEN_TEMPLATE_DIR
22
+ try {
23
+ return dirname(createRequire(import.meta.url).resolve('@meddleware/sui-token-template/package.json'))
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+ const T = resolveTemplateDir()
29
+
30
+ // Mirrors the key -> source mapping in scripts/sync-template-src.sh.
31
+ const SOURCE_OF: Record<string, string> = {
32
+ 'Move.toml': 'Move.toml',
33
+ 'source.move': 'sources/sui_token_template.move',
34
+ 'scripts/publish.sh': 'templates/publish.sh',
35
+ '.gitignore': 'templates/.gitignore',
36
+ 'README.md': 'templates/README.md',
37
+ 'deployments.md': 'templates/deployments.md',
38
+ 'CLAUDE.md': 'templates/CLAUDE.md',
39
+ 'AGENTS.md': 'templates/AGENTS.md',
40
+ }
41
+
42
+ describe('template-src parity with the canonical sui-token-template', () => {
43
+ const templatePresent = T !== null && existsSync(`${T}/Move.toml`)
44
+
45
+ it('bundles exactly the expected file keys', () => {
46
+ expect(Object.keys(files).sort()).toEqual(Object.keys(SOURCE_OF).sort())
47
+ })
48
+
49
+ for (const [key, rel] of Object.entries(SOURCE_OF)) {
50
+ it.skipIf(!templatePresent)(`files.json["${key}"] matches ${rel} verbatim`, () => {
51
+ const canonical = readFileSync(`${T as string}/${rel}`, 'utf8')
52
+ expect(files[key]).toBe(canonical)
53
+ })
54
+ }
55
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { useAccessGate } from '../src/composables/useAccessGate.js'
3
+ import type { AccessGateConfig, OwnedObjectsClient } from '../src/lib/accessGate.js'
4
+
5
+ afterEach(() => vi.restoreAllMocks())
6
+
7
+ const GATE = '0x00000000000000000000000000000000000000000000000000000000000000a1'
8
+ const NFT_TYPE = '0xpkg::access_gate::AccessNFT'
9
+
10
+ const gate: AccessGateConfig = {
11
+ packageId: '0xpkg',
12
+ gateId: GATE,
13
+ nftType: NFT_TYPE,
14
+ soulbound: true,
15
+ priceMist: 1000n,
16
+ }
17
+
18
+ function ownedEntry(uses?: number) {
19
+ return {
20
+ data: {
21
+ objectId: '0xnft',
22
+ content: {
23
+ fields: {
24
+ data: {
25
+ fields: {
26
+ gate_id: GATE,
27
+ variant:
28
+ uses === undefined
29
+ ? { variant: 'UnlimitedPass', fields: {} }
30
+ : { variant: 'SingleUse', fields: { uses_remaining: String(uses) } },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ },
36
+ }
37
+ }
38
+
39
+ function clientReturning(data: unknown[]): OwnedObjectsClient {
40
+ return { getOwnedObjects: vi.fn(async () => ({ data })) }
41
+ }
42
+
43
+ describe('useAccessGate', () => {
44
+ it('treats the relay as open when no gate is configured', async () => {
45
+ const g = useAccessGate('testnet', { getClient: () => clientReturning([]), gate: null })
46
+ expect(g.gateConfigured).toBe(false)
47
+ expect(g.hasAccess.value).toBe(true)
48
+ await g.checkOwnership('0xowner')
49
+ expect(g.accessGranted.value).toBe(true)
50
+ })
51
+
52
+ it('grants access when the wallet holds the gate NFT', async () => {
53
+ const g = useAccessGate('testnet', { getClient: () => clientReturning([ownedEntry(3)]), gate })
54
+ expect(g.hasAccess.value).toBe(null) // unknown until checked
55
+ await g.checkOwnership('0xowner')
56
+ expect(g.hasAccess.value).toBe(true)
57
+ expect(g.usesRemaining.value).toBe(3)
58
+ expect(g.accessGranted.value).toBe(true)
59
+ })
60
+
61
+ it('denies access when the wallet holds no matching NFT', async () => {
62
+ const g = useAccessGate('testnet', { getClient: () => clientReturning([]), gate })
63
+ await g.checkOwnership('0xowner')
64
+ expect(g.hasAccess.value).toBe(false)
65
+ expect(g.accessGranted.value).toBe(false)
66
+ })
67
+
68
+ it('surfaces an error and denies (does not throw) when the query fails', async () => {
69
+ const g = useAccessGate('testnet', {
70
+ getClient: () => ({
71
+ getOwnedObjects: vi.fn(async () => {
72
+ throw new Error('rpc down')
73
+ }),
74
+ }),
75
+ gate,
76
+ })
77
+ await g.checkOwnership('0xowner')
78
+ expect(g.hasAccess.value).toBe(false)
79
+ expect(g.error.value).toMatch(/rpc down/)
80
+ })
81
+
82
+ it('builds a relay access token by signing the challenge', async () => {
83
+ vi.stubGlobal(
84
+ 'fetch',
85
+ vi.fn(async () => new Response(JSON.stringify({ nonce: 'the-nonce', expiresAt: 1 }), { status: 200 })),
86
+ )
87
+ const g = useAccessGate('testnet', { getClient: () => clientReturning([]), gate })
88
+ const sign = vi.fn(async (message: Uint8Array) => {
89
+ expect(new TextDecoder().decode(message)).toBe('nft-gate:access:the-nonce')
90
+ return { signature: 'SIG' }
91
+ })
92
+ const token = await g.buildRelayAccessToken({
93
+ relayHost: 'https://relay.example',
94
+ address: '0xowner',
95
+ sign,
96
+ consumeDigest: 'dig',
97
+ })
98
+ const decoded = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'))
99
+ expect(decoded).toEqual({ address: '0xowner', nonce: 'the-nonce', signature: 'SIG', consumeDigest: 'dig' })
100
+ })
101
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { readonly, isReadonly } from 'vue'
3
+
4
+ // Reproduces a real Sui wallet extension: `name`/`icon` are getters backed by an
5
+ // ES private field, so they throw ("attempted to get private field on
6
+ // non-instance") if invoked with a Vue reactive/readonly Proxy as `this`. Plain
7
+ // object literals (like our e2e mock) do NOT have this property, which is why the
8
+ // bug only surfaced with actual installed wallets.
9
+ class PrivateFieldWallet {
10
+ #name = 'Slush'
11
+ #icon = 'data:image/svg+xml;base64,AAAA'
12
+ get name() {
13
+ return this.#name
14
+ }
15
+ get icon() {
16
+ return this.#icon
17
+ }
18
+ version = '1.0.0'
19
+ chains = ['sui:testnet']
20
+ accounts = [
21
+ {
22
+ address: '0x' + '1'.repeat(64),
23
+ publicKey: new Uint8Array(),
24
+ chains: ['sui:testnet'],
25
+ features: ['sui:signTransaction'],
26
+ label: 'Account 1',
27
+ },
28
+ ]
29
+ features = {
30
+ 'standard:connect': { version: '1.0.0', connect: async () => ({ accounts: this.accounts }) },
31
+ 'standard:events': { version: '1.0.0', on: () => () => {} },
32
+ 'sui:signTransaction': { version: '2.0.0', signTransaction: async () => ({ bytes: '', signature: '' }) },
33
+ 'sui:signPersonalMessage': {
34
+ version: '1.0.0',
35
+ signPersonalMessage: async () => ({ bytes: '', signature: 'PMSIG' }),
36
+ },
37
+ }
38
+ }
39
+
40
+ const wallet = new PrivateFieldWallet()
41
+
42
+ vi.mock('@mysten/wallet-standard', () => ({
43
+ getWallets: () => ({ get: () => [wallet], on: () => {} }),
44
+ isWalletWithRequiredFeatureSet: () => true,
45
+ }))
46
+
47
+ describe('useWallet with real extension wallets (private-field getters)', () => {
48
+ it('sanity: a private-field getter DOES throw through a Vue readonly Proxy', () => {
49
+ // This is the exact failure the composable must avoid; if this ever stops
50
+ // throwing the guard below is testing nothing.
51
+ const proxied = readonly(new PrivateFieldWallet()) as PrivateFieldWallet
52
+ expect(() => proxied.name).toThrow('Cannot read private member')
53
+ })
54
+
55
+ it('exposes discovered wallets un-proxied so name/icon read safely', async () => {
56
+ const { useWallet } = await import('@meddleware/wallet-adapter')
57
+ const { wallets } = useWallet()
58
+ const list = wallets.value
59
+ expect(list.length).toBe(1)
60
+ // markRaw means Vue never wraps the wallet, so the getters use the real `this`
61
+ expect(isReadonly(list[0])).toBe(false)
62
+ expect(() => list[0].name).not.toThrow()
63
+ expect(list[0].name).toBe('Slush')
64
+ expect(list[0].icon).toContain('data:image')
65
+ })
66
+
67
+ it('signs a personal message via the wallet feature (for the NFT-gate challenge)', async () => {
68
+ const { useWallet } = await import('@meddleware/wallet-adapter')
69
+ const { connect, signPersonalMessage, wallets } = useWallet()
70
+ await connect(wallets.value[0])
71
+ const res = await signPersonalMessage(new TextEncoder().encode('nft-gate:access:abc'))
72
+ expect(res.signature).toBe('PMSIG')
73
+ })
74
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { useWalrusRelay } from '../src/composables/useWalrusRelay.js'
4
+
5
+ // These tests cover the NFT-gate logic layered onto relay selection. The operator-relay
6
+ // visibility also depends on operator-relay env config (not set in unit tests), so we assert
7
+ // on `accessSatisfied`, which is a pure function of gate config + ownership.
8
+
9
+ describe('useWalrusRelay gating', () => {
10
+ it('is unrestricted when no gate is configured (unchanged behaviour)', () => {
11
+ const relay = useWalrusRelay('testnet')
12
+ expect(relay.accessSatisfied.value).toBe(true)
13
+ // The public relay is always present as a fallback.
14
+ expect(relay.availableRelays.value.some((r) => r.isPublic)).toBe(true)
15
+ })
16
+
17
+ it('reflects ownership when a gate is configured', () => {
18
+ const hasAccess = ref<boolean | null>(null)
19
+ const relay = useWalrusRelay('testnet', { gateConfigured: true, hasAccess })
20
+ expect(relay.accessSatisfied.value).toBe(false) // unknown ⇒ not satisfied
21
+
22
+ hasAccess.value = true
23
+ expect(relay.accessSatisfied.value).toBe(true)
24
+
25
+ hasAccess.value = false
26
+ expect(relay.accessSatisfied.value).toBe(false)
27
+ // Public relay still available regardless of gate access.
28
+ expect(relay.availableRelays.value.some((r) => r.isPublic)).toBe(true)
29
+ })
30
+ })
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ validateForm,
4
+ deriveStructName,
5
+ parseSupply,
6
+ isValid,
7
+ validateIdentifier,
8
+ hasAllowedIconScheme,
9
+ MAX_IDENT_LEN,
10
+ } from '../src/lib/validation.js'
11
+
12
+ const good = {
13
+ packageName: 'my_token',
14
+ moduleName: 'mytoken',
15
+ symbol: 'MTK',
16
+ name: 'My Token',
17
+ description: 'A friendly token',
18
+ iconUrl: 'https://example.com/i.png',
19
+ decimals: 9,
20
+ initialSupply: '1000000',
21
+ recipient: '',
22
+ }
23
+
24
+ describe('deriveStructName', () => {
25
+ it('uppercases the module name (Sui OTW rule)', () => {
26
+ expect(deriveStructName('mwsui')).toBe('MWSUI')
27
+ expect(deriveStructName('sui_token_template')).toBe('SUI_TOKEN_TEMPLATE')
28
+ })
29
+ })
30
+
31
+ describe('validateForm', () => {
32
+ it('accepts a well-formed config', () => {
33
+ expect(isValid(validateForm(good))).toBe(true)
34
+ })
35
+
36
+ it('rejects invalid identifiers', () => {
37
+ expect(validateForm({ ...good, packageName: 'My-Token' }).packageName).toBeTruthy()
38
+ expect(validateForm({ ...good, moduleName: '1coin' }).moduleName).toBeTruthy()
39
+ })
40
+
41
+ it('rejects unsafe characters that would break the Move byte-string', () => {
42
+ expect(validateForm({ ...good, symbol: 'A"B' }).symbol).toBeTruthy()
43
+ expect(validateForm({ ...good, name: 'back\\slash' }).name).toBeTruthy()
44
+ })
45
+
46
+ it('requires symbol and name', () => {
47
+ expect(validateForm({ ...good, symbol: '' }).symbol).toBeTruthy()
48
+ expect(validateForm({ ...good, name: ' ' }).name).toBeTruthy()
49
+ })
50
+
51
+ it('bounds decimals to 0..18', () => {
52
+ expect(validateForm({ ...good, decimals: 19 }).decimals).toBeTruthy()
53
+ expect(validateForm({ ...good, decimals: -1 }).decimals).toBeTruthy()
54
+ expect(validateForm({ ...good, decimals: 6 }).decimals).toBeUndefined()
55
+ })
56
+
57
+ it('rejects supply that overflows u64 for the decimals', () => {
58
+ expect(validateForm({ ...good, initialSupply: '99999999999999', decimals: 18 }).initialSupply).toBeTruthy()
59
+ expect(validateForm({ ...good, initialSupply: 'abc' }).initialSupply).toBeTruthy()
60
+ expect(validateForm({ ...good, initialSupply: '' }).initialSupply).toBeUndefined()
61
+ })
62
+
63
+ it('validates an optional recipient address', () => {
64
+ expect(validateForm({ ...good, recipient: '0x123' }).recipient).toBeTruthy()
65
+ expect(validateForm({ ...good, recipient: '0x' + 'a'.repeat(64) }).recipient).toBeUndefined()
66
+ })
67
+
68
+ it('rejects Move reserved words as package/module names', () => {
69
+ expect(validateForm({ ...good, packageName: 'module' }).packageName).toBeTruthy()
70
+ expect(validateForm({ ...good, moduleName: 'struct' }).moduleName).toBeTruthy()
71
+ expect(validateForm({ ...good, moduleName: 'public' }).moduleName).toBeTruthy()
72
+ })
73
+
74
+ it('rejects over-length identifiers', () => {
75
+ const tooLong = 'a'.repeat(MAX_IDENT_LEN + 1)
76
+ expect(validateForm({ ...good, packageName: tooLong }).packageName).toBeTruthy()
77
+ expect(validateForm({ ...good, moduleName: 'a'.repeat(MAX_IDENT_LEN) }).moduleName).toBeUndefined()
78
+ })
79
+
80
+ it('rejects a whitespace-only symbol even though it contains "safe" chars', () => {
81
+ expect(validateForm({ ...good, symbol: ' ' }).symbol).toBeTruthy()
82
+ })
83
+ })
84
+
85
+ describe('iconUrl scheme allowlist', () => {
86
+ it('accepts https:// and ipfs:// (and empty = no icon)', () => {
87
+ expect(isValid(validateForm({ ...good, iconUrl: 'https://x.io/a.png' }))).toBe(true)
88
+ expect(isValid(validateForm({ ...good, iconUrl: 'ipfs://bafy.../a.png' }))).toBe(true)
89
+ expect(isValid(validateForm({ ...good, iconUrl: '' }))).toBe(true)
90
+ expect(hasAllowedIconScheme('HTTPS://X.IO/a.png')).toBe(true) // case-insensitive
91
+ })
92
+
93
+ it('rejects javascript:, data:, and insecure http:', () => {
94
+ expect(validateForm({ ...good, iconUrl: 'javascript:alert(1)' }).iconUrl).toBeTruthy()
95
+ expect(validateForm({ ...good, iconUrl: 'data:image/png;base64,AAAA' }).iconUrl).toBeTruthy()
96
+ expect(validateForm({ ...good, iconUrl: 'http://x.io/a.png' }).iconUrl).toBeTruthy()
97
+ expect(hasAllowedIconScheme('javascript:alert(1)')).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe('validateIdentifier', () => {
102
+ it('accepts a valid identifier and rejects shape/keyword/length violations', () => {
103
+ expect(validateIdentifier('my_token')).toBeNull()
104
+ expect(validateIdentifier('My-Token')).toBeTruthy()
105
+ expect(validateIdentifier('const')).toBeTruthy()
106
+ expect(validateIdentifier('a'.repeat(MAX_IDENT_LEN + 1))).toBeTruthy()
107
+ })
108
+ })
109
+
110
+ describe('parseSupply', () => {
111
+ it('parses whole numbers and blank', () => {
112
+ expect(parseSupply('')).toBe(0n)
113
+ expect(parseSupply(' 42 ')).toBe(42n)
114
+ expect(parseSupply('1.5')).toBeNull()
115
+ expect(parseSupply('-1')).toBeNull()
116
+ })
117
+ })