@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,205 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, computed } from 'vue'
|
|
3
|
+
import { useWallet } from '@meddleware/wallet-adapter'
|
|
4
|
+
import { buildDeployExecutor } from '../lib/deployExecutor.js'
|
|
5
|
+
import { useWalrusRelay } from '../composables/useWalrusRelay.js'
|
|
6
|
+
import { ICON_EPOCHS } from '../lib/walrus-constants.js'
|
|
7
|
+
import { ICON_MAX_BYTES, validateIconFile } from '../config.js'
|
|
8
|
+
import type { WalrusNetwork } from '../lib/walrus.js'
|
|
9
|
+
import type { Network } from '../lib/types.js'
|
|
10
|
+
import AppNotice from './AppNotice.vue'
|
|
11
|
+
// Vite serves the Walrus wasm as an asset URL (lightweight import — just the URL).
|
|
12
|
+
import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{ network: WalrusNetwork; connected: boolean }>()
|
|
15
|
+
const emit = defineEmits<{ (e: 'uploaded', url: string): void }>()
|
|
16
|
+
|
|
17
|
+
const { account } = useWallet()
|
|
18
|
+
const {
|
|
19
|
+
selectedRelayHost,
|
|
20
|
+
availableRelays,
|
|
21
|
+
estimatedCost,
|
|
22
|
+
fileSizeBytes,
|
|
23
|
+
checkOperatorRelayHealth,
|
|
24
|
+
} = useWalrusRelay(props.network)
|
|
25
|
+
|
|
26
|
+
const uploading = ref(false)
|
|
27
|
+
const status = ref('')
|
|
28
|
+
const error = ref<string | null>(null)
|
|
29
|
+
const fileName = ref('')
|
|
30
|
+
let bytes: Uint8Array | null = null
|
|
31
|
+
|
|
32
|
+
const maxSizeLabel = `${Math.round(ICON_MAX_BYTES / 1024)} KB`
|
|
33
|
+
const ICON_YEARS = Math.round(ICON_EPOCHS / 26)
|
|
34
|
+
|
|
35
|
+
onMounted(() => {
|
|
36
|
+
checkOperatorRelayHealth()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const showPublicRelayNotice = computed(() => availableRelays.value.length > 1)
|
|
40
|
+
|
|
41
|
+
const showFreeRelayTip = computed(() => {
|
|
42
|
+
const publicHost = availableRelays.value.find((r) => r.isPublic)?.host
|
|
43
|
+
return showPublicRelayNotice.value && selectedRelayHost.value !== publicHost
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function onFile(e: Event): void {
|
|
47
|
+
error.value = null
|
|
48
|
+
bytes = null
|
|
49
|
+
fileName.value = ''
|
|
50
|
+
const f = (e.target as HTMLInputElement).files?.[0]
|
|
51
|
+
if (!f) return
|
|
52
|
+
|
|
53
|
+
// UX/cost guard only — the authoritative cap is the edge (relay) body limit.
|
|
54
|
+
const invalid = validateIconFile(f)
|
|
55
|
+
if (invalid) {
|
|
56
|
+
error.value = invalid
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fileName.value = f.name
|
|
61
|
+
fileSizeBytes.value = f.size
|
|
62
|
+
const reader = new FileReader()
|
|
63
|
+
reader.onload = () => {
|
|
64
|
+
bytes = new Uint8Array(reader.result as ArrayBuffer)
|
|
65
|
+
}
|
|
66
|
+
reader.readAsArrayBuffer(f)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function onRelayChange(newHost: string): void {
|
|
70
|
+
selectedRelayHost.value = newHost
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function upload(): Promise<void> {
|
|
74
|
+
if (!bytes) {
|
|
75
|
+
error.value = 'Choose an image first.'
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (!account.value) {
|
|
79
|
+
error.value = 'Connect your wallet first.'
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
uploading.value = true
|
|
83
|
+
error.value = null
|
|
84
|
+
try {
|
|
85
|
+
// Lazily load the heavy @mysten/walrus code and Sui client only on demand.
|
|
86
|
+
const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } =
|
|
87
|
+
await import('../lib/walrus.js')
|
|
88
|
+
const { getReadClient } = await import('../lib/readClient.js')
|
|
89
|
+
const executor = await buildDeployExecutor(props.network as Network)
|
|
90
|
+
const suiClient = getReadClient(props.network as Network)
|
|
91
|
+
const client = createWalrusClient(props.network, {
|
|
92
|
+
wasmUrl: walrusWasmUrl,
|
|
93
|
+
uploadRelayHost: selectedRelayHost.value,
|
|
94
|
+
})
|
|
95
|
+
const flow = createBlobUploadFlow(client, bytes)
|
|
96
|
+
|
|
97
|
+
status.value = 'Encoding…'
|
|
98
|
+
await flow.encode()
|
|
99
|
+
|
|
100
|
+
// Disclose the relay fee before asking for a signature. `calculateUploadRelayTip`
|
|
101
|
+
// takes the UNENCODED size and throws if the tip exceeds the client max guard.
|
|
102
|
+
let feeNote = ''
|
|
103
|
+
try {
|
|
104
|
+
const tipMist = await client.walrus.calculateUploadRelayTip({ size: bytes.length })
|
|
105
|
+
const tipSui = Number(tipMist) / 1e9
|
|
106
|
+
feeNote = tipMist > 0n ? ` Relay fee ≈ ${tipSui.toFixed(4)} SUI + storage (WAL).` : ''
|
|
107
|
+
} catch {
|
|
108
|
+
// Non-fatal for disclosure; the register step surfaces any real tip error.
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
status.value = `Registering blob (approve in wallet)…${feeNote}`
|
|
112
|
+
const regTx = flow.register({
|
|
113
|
+
owner: account.value.address,
|
|
114
|
+
epochs: ICON_EPOCHS,
|
|
115
|
+
deletable: false,
|
|
116
|
+
})
|
|
117
|
+
// Set sender and build with the Sui client to resolve CoinWithBalance intents
|
|
118
|
+
regTx.setSenderIfNotSet(account.value.address)
|
|
119
|
+
await regTx.build({ client: suiClient })
|
|
120
|
+
const reg = await executor.signAndExecute(regTx)
|
|
121
|
+
await executor.waitForTransaction(reg.digest)
|
|
122
|
+
|
|
123
|
+
status.value = 'Uploading to the relay…'
|
|
124
|
+
await flow.upload({ digest: reg.digest })
|
|
125
|
+
|
|
126
|
+
status.value = 'Certifying (approve in wallet)…'
|
|
127
|
+
const certTx = flow.certify()
|
|
128
|
+
// Set sender and build the certify transaction with the Sui client
|
|
129
|
+
certTx.setSenderIfNotSet(account.value.address)
|
|
130
|
+
await certTx.build({ client: suiClient })
|
|
131
|
+
const cert = await executor.signAndExecute(certTx)
|
|
132
|
+
await executor.waitForTransaction(cert.digest)
|
|
133
|
+
|
|
134
|
+
const blob = await flow.getBlob()
|
|
135
|
+
emit('uploaded', walrusBlobUrl(props.network, blob.blobId))
|
|
136
|
+
status.value = 'Uploaded ✓ — URL filled in above.'
|
|
137
|
+
} catch (e) {
|
|
138
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
139
|
+
status.value = ''
|
|
140
|
+
} finally {
|
|
141
|
+
uploading.value = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<div>
|
|
148
|
+
<label for="walrus-file" class="hint" style="display: block; font-weight: 600">
|
|
149
|
+
…or upload an image to Walrus
|
|
150
|
+
</label>
|
|
151
|
+
<div class="row">
|
|
152
|
+
<input
|
|
153
|
+
id="walrus-file"
|
|
154
|
+
type="file"
|
|
155
|
+
accept="image/*"
|
|
156
|
+
aria-label="Choose an image to upload to Walrus"
|
|
157
|
+
aria-describedby="walrus-help"
|
|
158
|
+
style="width: auto"
|
|
159
|
+
@change="onFile"
|
|
160
|
+
/>
|
|
161
|
+
<button type="button" :disabled="uploading || !connected || !fileName" @click="upload">
|
|
162
|
+
<span v-if="uploading" class="spinner" aria-hidden="true"></span>
|
|
163
|
+
Upload to Walrus
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<!-- Relay selection (shown only if multiple relays available) -->
|
|
168
|
+
<fieldset v-if="fileName && availableRelays.length > 1" style="margin: 0.75rem 0">
|
|
169
|
+
<legend>Upload relay</legend>
|
|
170
|
+
<div style="display: flex; flex-direction: column; gap: 0.5rem">
|
|
171
|
+
<label
|
|
172
|
+
v-for="option in availableRelays"
|
|
173
|
+
:key="option.host"
|
|
174
|
+
style="display: flex; align-items: center; gap: 0.5rem"
|
|
175
|
+
>
|
|
176
|
+
<input
|
|
177
|
+
type="radio"
|
|
178
|
+
:value="option.host"
|
|
179
|
+
:checked="selectedRelayHost === option.host"
|
|
180
|
+
@change="(e) => onRelayChange((e.target as HTMLInputElement).value)"
|
|
181
|
+
/>
|
|
182
|
+
<span>{{ option.label }}</span>
|
|
183
|
+
</label>
|
|
184
|
+
</div>
|
|
185
|
+
</fieldset>
|
|
186
|
+
|
|
187
|
+
<!-- Cost estimation (shown after file selection) -->
|
|
188
|
+
<AppNotice v-if="fileName && estimatedCost" style="margin: 0.75rem 0">
|
|
189
|
+
<strong>Estimated cost:</strong> {{ estimatedCost.label }}
|
|
190
|
+
</AppNotice>
|
|
191
|
+
|
|
192
|
+
<!-- Public relay notice -->
|
|
193
|
+
<p v-if="showFreeRelayTip" class="hint" style="margin: 0.75rem 0">
|
|
194
|
+
Tip: select the public relay above to avoid the relay fee.
|
|
195
|
+
</p>
|
|
196
|
+
|
|
197
|
+
<p id="walrus-help" class="hint" aria-live="polite">
|
|
198
|
+
Max {{ maxSizeLabel }} · PNG/JPEG/WebP/SVG. Requires <strong>WAL</strong> (storage) and
|
|
199
|
+
<strong>SUI</strong> (gas + a small relay fee) in your wallet; two wallet approvals. Blob
|
|
200
|
+
stored for ~{{ ICON_YEARS }} years.
|
|
201
|
+
<span v-if="status">{{ status }}</span>
|
|
202
|
+
</p>
|
|
203
|
+
<AppNotice v-if="error" type="error">{{ error }}</AppNotice>
|
|
204
|
+
</div>
|
|
205
|
+
</template>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import { ACCESS_GATE } from '../config.js'
|
|
3
|
+
import type { WalrusNetwork } from '../config.js'
|
|
4
|
+
import {
|
|
5
|
+
fetchAccessNfts,
|
|
6
|
+
buildPurchaseTx,
|
|
7
|
+
buildConsumeTx,
|
|
8
|
+
fetchRelayChallenge,
|
|
9
|
+
personalMessageForNonce,
|
|
10
|
+
buildAccessProofToken,
|
|
11
|
+
} from '../lib/accessGate.js'
|
|
12
|
+
import type { AccessGateConfig, OwnedObjectsClient } from '../lib/accessGate.js'
|
|
13
|
+
import type { Executor } from '../lib/deploy.js'
|
|
14
|
+
import type { Transaction } from '@mysten/sui/transactions'
|
|
15
|
+
|
|
16
|
+
/** A wallet personal-message signer, as exposed by `useWallet().signPersonalMessage`. */
|
|
17
|
+
export type PersonalMessageSigner = (message: Uint8Array) => Promise<{ signature: string }>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reactive NFT-gate state for the operator relay.
|
|
21
|
+
*
|
|
22
|
+
* When no gate is configured for the network (`ACCESS_GATE[network] == null`), the relay is
|
|
23
|
+
* treated as OPEN (`hasAccess === true`) and this composable is inert — behaviour is
|
|
24
|
+
* unchanged from before gating existed. When a gate IS configured, `checkOwnership` decides
|
|
25
|
+
* access with a single `getOwnedObjects` call (the same cheap pattern as the relay health
|
|
26
|
+
* check), and `purchase`/`buildRelayAccessToken` drive the buy + prove flows.
|
|
27
|
+
*/
|
|
28
|
+
export function useAccessGate(
|
|
29
|
+
network: WalrusNetwork,
|
|
30
|
+
deps: {
|
|
31
|
+
getClient: (network: WalrusNetwork) => OwnedObjectsClient
|
|
32
|
+
/** Override the configured gate (mainly for testing); defaults to `ACCESS_GATE[network]`. */
|
|
33
|
+
gate?: AccessGateConfig | null
|
|
34
|
+
},
|
|
35
|
+
) {
|
|
36
|
+
const gate: AccessGateConfig | null = deps.gate !== undefined ? deps.gate : ACCESS_GATE[network]
|
|
37
|
+
const gateConfigured = gate !== null
|
|
38
|
+
|
|
39
|
+
// No gate → open. Gate → unknown until checked.
|
|
40
|
+
const hasAccess = ref<boolean | null>(gateConfigured ? null : true)
|
|
41
|
+
const usesRemaining = ref<number | null>(null)
|
|
42
|
+
/** Object id of the held access NFT (for the single-use consume step); null if none. */
|
|
43
|
+
const nftId = ref<string | null>(null)
|
|
44
|
+
const checking = ref(false)
|
|
45
|
+
const error = ref<string | null>(null)
|
|
46
|
+
|
|
47
|
+
/** Query whether `address` holds the gate NFT. Cheap; safe to call on connect. */
|
|
48
|
+
async function checkOwnership(address: string): Promise<void> {
|
|
49
|
+
if (!gate) {
|
|
50
|
+
hasAccess.value = true
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
checking.value = true
|
|
54
|
+
error.value = null
|
|
55
|
+
try {
|
|
56
|
+
const nfts = await fetchAccessNfts(deps.getClient(network), address, gate.nftType, gate.gateId)
|
|
57
|
+
hasAccess.value = nfts.length > 0
|
|
58
|
+
usesRemaining.value = nfts.length ? nfts[0].usesRemaining : null
|
|
59
|
+
nftId.value = nfts.length ? nfts[0].objectId : null
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// A failed check must NOT hard-block the user: leave access unknown/false but keep the
|
|
62
|
+
// purchase path available (the gateway re-verifies server-side regardless).
|
|
63
|
+
hasAccess.value = false
|
|
64
|
+
nftId.value = null
|
|
65
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
66
|
+
} finally {
|
|
67
|
+
checking.value = false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Purchase access via the connected wallet, then re-check ownership. */
|
|
72
|
+
async function purchase(executor: Executor, address: string): Promise<void> {
|
|
73
|
+
if (!gate) throw new Error('No access gate configured for this network.')
|
|
74
|
+
const tx = buildPurchaseTx(gate)
|
|
75
|
+
const res = await executor.signAndExecute(tx)
|
|
76
|
+
if (res.digest) await executor.waitForTransaction(res.digest).catch(() => {})
|
|
77
|
+
await checkOwnership(address)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the consume PTB for a single-use NFT (woven into the upload flow before proving). */
|
|
81
|
+
function buildConsume(nftId: string, nonce: string): Transaction {
|
|
82
|
+
if (!gate) throw new Error('No access gate configured for this network.')
|
|
83
|
+
return buildConsumeTx(gate, nftId, nonce)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fetch a challenge from the gateway (served at the operator relay host), sign it, and
|
|
88
|
+
* return the base64 access-proof token to pass to the Walrus client as the relay auth
|
|
89
|
+
* token. For single-use gates supply the on-chain consume `consumeDigest`.
|
|
90
|
+
*/
|
|
91
|
+
async function buildRelayAccessToken(opts: {
|
|
92
|
+
relayHost: string
|
|
93
|
+
address: string
|
|
94
|
+
sign: PersonalMessageSigner
|
|
95
|
+
consumeDigest?: string
|
|
96
|
+
}): Promise<string> {
|
|
97
|
+
const challenge = await fetchRelayChallenge(opts.relayHost)
|
|
98
|
+
const { signature } = await opts.sign(personalMessageForNonce(challenge.nonce))
|
|
99
|
+
return buildAccessProofToken({
|
|
100
|
+
address: opts.address,
|
|
101
|
+
nonce: challenge.nonce,
|
|
102
|
+
signature,
|
|
103
|
+
consumeDigest: opts.consumeDigest,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
gate,
|
|
109
|
+
gateConfigured,
|
|
110
|
+
hasAccess,
|
|
111
|
+
usesRemaining,
|
|
112
|
+
nftId,
|
|
113
|
+
checking,
|
|
114
|
+
error,
|
|
115
|
+
/** Convenience: gate configured AND access confirmed. */
|
|
116
|
+
accessGranted: computed(() => !gateConfigured || hasAccess.value === true),
|
|
117
|
+
checkOwnership,
|
|
118
|
+
purchase,
|
|
119
|
+
buildConsume,
|
|
120
|
+
buildRelayAccessToken,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ref, watch, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Colour-mode state manager (light | dark | system). Sets `data-theme` on
|
|
5
|
+
* <html>, persists the choice, and follows the OS in `system` mode.
|
|
6
|
+
*
|
|
7
|
+
* Vendored mirror of `@meddleware/ui`'s `useColorMode` for visual/UX parity with
|
|
8
|
+
* the dashboard — this repo is self-contained (no cross-repo links).
|
|
9
|
+
* TODO(unify): consume `@meddleware/ui` once it is published.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ColorMode = 'light' | 'dark' | 'system'
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = 'sui-deployer:color-mode'
|
|
15
|
+
|
|
16
|
+
const mode: Ref<ColorMode> = ref<ColorMode>('system')
|
|
17
|
+
let initialized = false
|
|
18
|
+
|
|
19
|
+
function systemPrefersDark(): boolean {
|
|
20
|
+
return (
|
|
21
|
+
typeof window !== 'undefined' &&
|
|
22
|
+
typeof window.matchMedia === 'function' &&
|
|
23
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveDark(m: ColorMode): boolean {
|
|
28
|
+
return m === 'dark' || (m === 'system' && systemPrefersDark())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function apply(m: ColorMode): void {
|
|
32
|
+
if (typeof document === 'undefined') return
|
|
33
|
+
document.documentElement.setAttribute('data-theme', resolveDark(m) ? 'dark' : 'light')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useColorMode(defaultMode: ColorMode = 'system') {
|
|
37
|
+
if (!initialized) {
|
|
38
|
+
initialized = true
|
|
39
|
+
let stored: ColorMode | null = null
|
|
40
|
+
try {
|
|
41
|
+
stored =
|
|
42
|
+
typeof localStorage !== 'undefined'
|
|
43
|
+
? (localStorage.getItem(STORAGE_KEY) as ColorMode | null)
|
|
44
|
+
: null
|
|
45
|
+
} catch {
|
|
46
|
+
/* no storage — ignore */
|
|
47
|
+
}
|
|
48
|
+
mode.value = stored ?? defaultMode
|
|
49
|
+
apply(mode.value)
|
|
50
|
+
|
|
51
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
52
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
53
|
+
if (mode.value === 'system') apply('system')
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
watch(mode, (m) => {
|
|
58
|
+
apply(m)
|
|
59
|
+
try {
|
|
60
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, m)
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function set(m: ColorMode): void {
|
|
68
|
+
mode.value = m
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cycle(): void {
|
|
72
|
+
set(mode.value === 'light' ? 'dark' : mode.value === 'dark' ? 'system' : 'light')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { mode, set, cycle, isDark: () => resolveDark(mode.value) }
|
|
76
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Thin wrapper around the shared `@meddleware/walrus-relay-ui` composable: injects
|
|
2
|
+
// this app's per-network relay host pair so the relay-selection + tip-estimation logic
|
|
3
|
+
// lives in exactly one place (DRY). The return shape is unchanged, so consumers
|
|
4
|
+
// (`WalrusIconUpload.vue`, `IconPicker.vue`) and tests are unaffected.
|
|
5
|
+
import { useWalrusRelay as useSharedWalrusRelay } from '@meddleware/walrus-relay'
|
|
6
|
+
import type { RelayAccessOptions, RelayOption } from '@meddleware/walrus-relay'
|
|
7
|
+
import { WALRUS_RELAY_HOSTS, PUBLIC_WALRUS_RELAY_HOSTS } from '../config.js'
|
|
8
|
+
import type { WalrusNetwork } from '../lib/walrus.js'
|
|
9
|
+
|
|
10
|
+
export type { RelayAccessOptions, RelayOption }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Manage Walrus relay selection and cost estimation for `network`.
|
|
14
|
+
* See `@meddleware/walrus-relay-ui`'s `useWalrusRelay` for the full behaviour.
|
|
15
|
+
*/
|
|
16
|
+
export function useWalrusRelay(network: WalrusNetwork, access: RelayAccessOptions = {}) {
|
|
17
|
+
return useSharedWalrusRelay(
|
|
18
|
+
{ operator: WALRUS_RELAY_HOSTS[network], public: PUBLIC_WALRUS_RELAY_HOSTS[network] },
|
|
19
|
+
access,
|
|
20
|
+
)
|
|
21
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Deployment-wide configuration. Operator values (fee + treasury) are read from
|
|
2
|
+
// Vite env vars so they can be set at build/deploy time without code changes.
|
|
3
|
+
// See .env.example.
|
|
4
|
+
import type { Network } from './lib/types.js'
|
|
5
|
+
import type { AccessGateConfig } from './lib/accessGate.js'
|
|
6
|
+
|
|
7
|
+
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public JSON-RPC fullnode URLs per network (overridable via `VITE_RPC_*`).
|
|
11
|
+
*
|
|
12
|
+
* NOTE: the official testnet fullnode (`fullnode.testnet.sui.io`) now serves gRPC
|
|
13
|
+
* only and returns 404 for JSON-RPC, so a JSON-RPC-capable endpoint is used for
|
|
14
|
+
* testnet. Operators should set `VITE_RPC_*` to their preferred (often paid) RPC
|
|
15
|
+
* for production to avoid public rate limits.
|
|
16
|
+
*/
|
|
17
|
+
export const RPC_URLS: Record<Network, string> = {
|
|
18
|
+
mainnet: env.VITE_RPC_MAINNET || 'https://fullnode.mainnet.sui.io:443',
|
|
19
|
+
testnet: env.VITE_RPC_TESTNET || 'https://sui-testnet-rpc.publicnode.com',
|
|
20
|
+
localnet: env.VITE_RPC_LOCALNET || 'http://127.0.0.1:9000',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Sui explorer base (coin/object links in the result screen). */
|
|
24
|
+
export function explorerObjectUrl(network: Network, id: string): string {
|
|
25
|
+
return `https://suiscan.xyz/${network}/object/${id}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The trivial fee (MIST), split from the user's gas coin into the operator
|
|
30
|
+
* treasury inside the publish PTB. Override with VITE_FEE_MIST.
|
|
31
|
+
*/
|
|
32
|
+
export const FEE_MIST: bigint = BigInt(env.VITE_FEE_MIST || '1000000000') // 1 SUI default
|
|
33
|
+
|
|
34
|
+
const ZERO_ADDR = '0x0000000000000000000000000000000000000000000000000000000000000000'
|
|
35
|
+
|
|
36
|
+
/** Operator treasury per network (set VITE_FEE_TREASURY_* before launch). */
|
|
37
|
+
export const FEE_TREASURY: Record<Network, string> = {
|
|
38
|
+
mainnet: env.VITE_FEE_TREASURY_MAINNET || ZERO_ADDR,
|
|
39
|
+
testnet: env.VITE_FEE_TREASURY_TESTNET || ZERO_ADDR,
|
|
40
|
+
localnet: env.VITE_FEE_TREASURY_LOCALNET || ZERO_ADDR,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when a real treasury has been configured for the network. */
|
|
44
|
+
export function isFeeConfigured(network: Network): boolean {
|
|
45
|
+
return FEE_TREASURY[network] !== ZERO_ADDR
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Gas budget for the publish transaction (MIST). */
|
|
49
|
+
export const PUBLISH_GAS_BUDGET: bigint = BigInt(env.VITE_PUBLISH_GAS_BUDGET || '500000000')
|
|
50
|
+
|
|
51
|
+
/** Networks selectable in the UI. Ship testnet first. */
|
|
52
|
+
export const SELECTABLE_NETWORKS: Network[] = ['testnet', 'mainnet']
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Walrus icon-upload configuration
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Walrus is a separate protocol from Sui; only testnet/mainnet exist (no
|
|
58
|
+
// localnet). Icon uploads go through an upload relay (direct-to-storage-node
|
|
59
|
+
// writes fail from browsers). See `src/lib/walrus.ts` and the relay runbook in
|
|
60
|
+
// README.md / CLAUDE.md for the operator side.
|
|
61
|
+
|
|
62
|
+
/** A Walrus network — a strict subset of {@link Network} (no localnet). */
|
|
63
|
+
export type WalrusNetwork = 'testnet' | 'mainnet'
|
|
64
|
+
|
|
65
|
+
/** Public Mysten-operated upload relays — the fallback when no operator relay is set. */
|
|
66
|
+
export const PUBLIC_WALRUS_RELAY_HOSTS: Record<WalrusNetwork, string> = {
|
|
67
|
+
testnet: 'https://upload-relay.testnet.walrus.space',
|
|
68
|
+
mainnet: 'https://upload-relay.mainnet.walrus.space',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Upload relay host per Walrus network.
|
|
73
|
+
*
|
|
74
|
+
* Fallback order: `VITE_WALRUS_RELAY_*` (the operator's own relay — the only way
|
|
75
|
+
* to *collect* the native tip as revenue) → the public Mysten relay. Uploads
|
|
76
|
+
* through the public relay still work but tip Mysten, not the operator, so they
|
|
77
|
+
* earn nothing. A user may also override this at runtime by entering a relay URL.
|
|
78
|
+
*/
|
|
79
|
+
export const WALRUS_RELAY_HOSTS: Record<WalrusNetwork, string> = {
|
|
80
|
+
testnet: env.VITE_WALRUS_RELAY_TESTNET || PUBLIC_WALRUS_RELAY_HOSTS.testnet,
|
|
81
|
+
mainnet: env.VITE_WALRUS_RELAY_MAINNET || PUBLIC_WALRUS_RELAY_HOSTS.mainnet,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when an operator-owned relay (not the public fallback) is configured. */
|
|
85
|
+
export function isOperatorRelayConfigured(network: WalrusNetwork): boolean {
|
|
86
|
+
return WALRUS_RELAY_HOSTS[network] !== PUBLIC_WALRUS_RELAY_HOSTS[network]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* gRPC fullnode URL for the Walrus icon-upload client (a `SuiGrpcClient`).
|
|
91
|
+
*
|
|
92
|
+
* IMPORTANT: this is a **gRPC** endpoint and is intentionally *separate* from
|
|
93
|
+
* {@link RPC_URLS} (the JSON-RPC endpoints used by the deploy path). The two protocols
|
|
94
|
+
* are not interchangeable — pointing the Walrus gRPC client at a JSON-RPC URL breaks it —
|
|
95
|
+
* so this has its own `VITE_WALRUS_RPC_*` override rather than reusing `VITE_RPC_*`.
|
|
96
|
+
* Defaults to the public gRPC fullnodes (rate-limited); set your own for production.
|
|
97
|
+
*/
|
|
98
|
+
export const WALRUS_RPC_URLS: Record<WalrusNetwork, string> = {
|
|
99
|
+
testnet: env.VITE_WALRUS_RPC_TESTNET || 'https://fullnode.testnet.sui.io:443',
|
|
100
|
+
mainnet: env.VITE_WALRUS_RPC_MAINNET || 'https://fullnode.mainnet.sui.io:443',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Maximum relay tip the client will pay, in MIST.
|
|
105
|
+
*
|
|
106
|
+
* COUPLING CONSTRAINT: the Walrus SDK **hard-fails** an upload (throws) if the
|
|
107
|
+
* relay's configured tip exceeds this value. It must therefore be set with
|
|
108
|
+
* generous headroom *above* whatever tip the operator's relay charges — raising
|
|
109
|
+
* the relay tip past this ceiling breaks uploads until the frontend is
|
|
110
|
+
* redeployed with a higher value. Default 0.05 SUI: comfortably above a
|
|
111
|
+
* cents-scale icon tip, well below anything a user would object to.
|
|
112
|
+
*/
|
|
113
|
+
export const WALRUS_MAX_TIP_MIST: bigint = BigInt(env.VITE_WALRUS_MAX_TIP_MIST || '50000000')
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// NFT-gated relay access (optional)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// When the operator relay runs behind an `nft-gate` gateway, using it requires
|
|
119
|
+
// holding the gate's access NFT. Configure the gate per network via env; when a
|
|
120
|
+
// gate is NOT configured the relay behaves exactly as before (no gating). See
|
|
121
|
+
// `src/lib/accessGate.ts` and `useAccessGate`.
|
|
122
|
+
|
|
123
|
+
function parseAccessGate(network: WalrusNetwork): AccessGateConfig | null {
|
|
124
|
+
const NET = network.toUpperCase()
|
|
125
|
+
const packageId = env[`VITE_ACCESS_GATE_PACKAGE_${NET}`]
|
|
126
|
+
const gateId = env[`VITE_ACCESS_GATE_ID_${NET}`]
|
|
127
|
+
const nftType = env[`VITE_ACCESS_GATE_NFT_TYPE_${NET}`]
|
|
128
|
+
if (!packageId || !gateId || !nftType) return null
|
|
129
|
+
return {
|
|
130
|
+
packageId,
|
|
131
|
+
gateId,
|
|
132
|
+
nftType,
|
|
133
|
+
soulbound: (env[`VITE_ACCESS_GATE_SOULBOUND_${NET}`] ?? 'true') !== 'false',
|
|
134
|
+
priceMist: BigInt(env[`VITE_ACCESS_GATE_PRICE_MIST_${NET}`] || '0'),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The access gate per Walrus network, or `null` when gating is not configured. */
|
|
139
|
+
export const ACCESS_GATE: Record<WalrusNetwork, AccessGateConfig | null> = {
|
|
140
|
+
testnet: parseAccessGate('testnet'),
|
|
141
|
+
mainnet: parseAccessGate('mainnet'),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True when an NFT gate is configured for the network's operator relay. */
|
|
145
|
+
export function isAccessGateConfigured(network: WalrusNetwork): boolean {
|
|
146
|
+
return ACCESS_GATE[network] !== null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Maximum icon file size, in bytes (default 100 KiB).
|
|
151
|
+
*
|
|
152
|
+
* This is a UX / predictable-cost guard only — NOT a security control. The
|
|
153
|
+
* authoritative cap is a hard request-body limit at the edge (Cloudflare /
|
|
154
|
+
* reverse proxy in front of the relay); a malicious client bypasses this one.
|
|
155
|
+
*/
|
|
156
|
+
export const ICON_MAX_BYTES: number = Number(env.VITE_ICON_MAX_BYTES || String(100 * 1024))
|
|
157
|
+
|
|
158
|
+
/** Accepted icon MIME types (override via `VITE_ICON_ALLOWED_TYPES`, comma-separated). */
|
|
159
|
+
export const ICON_ALLOWED_TYPES: string[] = (
|
|
160
|
+
env.VITE_ICON_ALLOWED_TYPES || 'image/png,image/jpeg,image/webp,image/svg+xml'
|
|
161
|
+
)
|
|
162
|
+
.split(',')
|
|
163
|
+
.map((t) => t.trim())
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate a chosen icon file against the {@link ICON_ALLOWED_TYPES} /
|
|
168
|
+
* {@link ICON_MAX_BYTES} gate. Returns a user-facing error message, or `null`
|
|
169
|
+
* when the file is acceptable.
|
|
170
|
+
*
|
|
171
|
+
* UX / predictable-cost guard only — NOT a security control (a malicious client
|
|
172
|
+
* bypasses it; the authoritative cap is the edge request-body limit).
|
|
173
|
+
*/
|
|
174
|
+
export function validateIconFile(file: { type: string; size: number }): string | null {
|
|
175
|
+
if (ICON_ALLOWED_TYPES.length && !ICON_ALLOWED_TYPES.includes(file.type)) {
|
|
176
|
+
const allowed = ICON_ALLOWED_TYPES.map((t) => t.replace('image/', '')).join(', ')
|
|
177
|
+
return `Unsupported image type${file.type ? ` (${file.type})` : ''}. Allowed: ${allowed}.`
|
|
178
|
+
}
|
|
179
|
+
if (file.size > ICON_MAX_BYTES) {
|
|
180
|
+
return `Image is too large (${Math.round(file.size / 1024)} KB). Maximum is ${Math.round(
|
|
181
|
+
ICON_MAX_BYTES / 1024,
|
|
182
|
+
)} KB.`
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as TokenDeployerView } from './components/TokenDeployerView.vue'
|