@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,141 @@
1
+ // Client-side helpers for the `access_gate` NFT primitive that gates the operator's
2
+ // upload relay: ownership queries, purchase/consume PTB builders, and the wallet-signed
3
+ // access-proof token.
4
+ //
5
+ // Self-contained (this app is standalone). The wire format mirrors
6
+ // `@meddleware/nft-gate-client` and `@meddleware/sui-walrus`'s access helpers; the
7
+ // `nft-gate` gateway is the authority that verifies these proofs.
8
+ //
9
+ // TODO(unify): consume `@meddleware/nft-gate-client` once published and drop the duplication.
10
+
11
+ import { Transaction } from '@mysten/sui/transactions'
12
+
13
+ /* eslint-disable @typescript-eslint/no-explicit-any */
14
+
15
+ /** Deployed gate + the NFT type that satisfies it. */
16
+ export interface AccessGateConfig {
17
+ packageId: string
18
+ gateId: string
19
+ /** `<pkg>::access_gate::AccessNFT` or `…::SoulboundAccessNFT`. */
20
+ nftType: string
21
+ soulbound: boolean
22
+ /** Purchase price in MIST. */
23
+ priceMist: bigint
24
+ }
25
+
26
+ export interface OwnedAccessNft {
27
+ objectId: string
28
+ gateId: string
29
+ /** `null` = unlimited pass; otherwise remaining single-use count. */
30
+ usesRemaining: number | null
31
+ }
32
+
33
+ /** Minimal structural subset of the JSON-RPC client used here. */
34
+ export interface OwnedObjectsClient {
35
+ getOwnedObjects(params: {
36
+ owner: string
37
+ filter?: { StructType: string }
38
+ options?: { showContent?: boolean; showType?: boolean }
39
+ }): Promise<{ data: unknown[] }>
40
+ }
41
+
42
+ function parseUsesRemaining(variant: any): number | null {
43
+ if (variant == null) return null
44
+ const fields = variant.fields ?? variant
45
+ const ur = fields?.uses_remaining ?? fields?.SingleUse?.uses_remaining
46
+ return ur != null ? Number(ur) : null
47
+ }
48
+
49
+ /** Parse a `getOwnedObjects` entry into an access NFT, or `null` if it isn't one. */
50
+ export function parseOwnedAccessNft(entry: any): OwnedAccessNft | null {
51
+ const obj = entry?.data ?? entry
52
+ const objectId: string | undefined = obj?.objectId
53
+ const inner = obj?.content?.fields?.data?.fields
54
+ const gateId: string | undefined = inner?.gate_id ?? inner?.gateId
55
+ if (!objectId || !gateId) return null
56
+ return { objectId, gateId, usesRemaining: parseUsesRemaining(inner?.variant) }
57
+ }
58
+
59
+ /** All access NFTs of `nftType` owned by `owner`, optionally restricted to `gateId`. */
60
+ export async function fetchAccessNfts(
61
+ client: OwnedObjectsClient,
62
+ owner: string,
63
+ nftType: string,
64
+ gateId?: string,
65
+ ): Promise<OwnedAccessNft[]> {
66
+ const { data } = await client.getOwnedObjects({
67
+ owner,
68
+ filter: { StructType: nftType },
69
+ options: { showContent: true, showType: true },
70
+ })
71
+ const parsed = (data ?? [])
72
+ .map(parseOwnedAccessNft)
73
+ .filter((n): n is OwnedAccessNft => n !== null)
74
+ return gateId ? parsed.filter((n) => n.gateId === gateId) : parsed
75
+ }
76
+
77
+ /** Build a PTB that buys access (splits the exact price from gas → `purchase`). */
78
+ export function buildPurchaseTx(cfg: AccessGateConfig): Transaction {
79
+ const tx = new Transaction()
80
+ const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(cfg.priceMist)])
81
+ tx.moveCall({
82
+ target: `${cfg.packageId}::access_gate::purchase`,
83
+ arguments: [tx.object(cfg.gateId), payment],
84
+ })
85
+ return tx
86
+ }
87
+
88
+ /** Build a PTB that consumes one single-use, binding it to `nonce`. */
89
+ export function buildConsumeTx(cfg: AccessGateConfig, nftId: string, nonce: string): Transaction {
90
+ const tx = new Transaction()
91
+ const fn = cfg.soulbound ? 'consume_soulbound' : 'consume'
92
+ const nonceBytes = Array.from(new TextEncoder().encode(nonce))
93
+ tx.moveCall({
94
+ target: `${cfg.packageId}::access_gate::${fn}`,
95
+ arguments: [tx.object(nftId), tx.object(cfg.gateId), tx.pure.vector('u8', nonceBytes)],
96
+ })
97
+ return tx
98
+ }
99
+
100
+ // ── Challenge / proof (mirror of the gateway's wire format) ───────────────────────
101
+
102
+ export interface RelayChallenge {
103
+ nonce: string
104
+ expiresAt: number
105
+ }
106
+
107
+ export type PersonalMessageSigner = (message: Uint8Array) => Promise<{ signature: string }>
108
+
109
+ export function personalMessageForNonce(nonce: string): Uint8Array {
110
+ return new TextEncoder().encode(`nft-gate:access:${nonce}`)
111
+ }
112
+
113
+ function toBase64(s: string): string {
114
+ return typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf-8').toString('base64')
115
+ }
116
+
117
+ export function buildAccessProofToken(proof: {
118
+ address: string
119
+ nonce: string
120
+ signature: string
121
+ consumeDigest?: string
122
+ }): string {
123
+ const payload: Record<string, string> = {
124
+ address: proof.address,
125
+ nonce: proof.nonce,
126
+ signature: proof.signature,
127
+ }
128
+ if (proof.consumeDigest) payload.consumeDigest = proof.consumeDigest
129
+ return toBase64(JSON.stringify(payload))
130
+ }
131
+
132
+ export async function fetchRelayChallenge(
133
+ relayHost: string,
134
+ opts: { signal?: AbortSignal } = {},
135
+ ): Promise<RelayChallenge> {
136
+ const res = await fetch(`${relayHost.replace(/\/$/, '')}/v1/challenge`, { signal: opts.signal })
137
+ if (!res.ok) throw new Error(`challenge request failed: ${res.status}`)
138
+ const data = (await res.json()) as { nonce?: string; expiresAt?: number; expires_at?: number }
139
+ if (!data || typeof data.nonce !== 'string') throw new Error('challenge response missing nonce')
140
+ return { nonce: data.nonce, expiresAt: data.expiresAt ?? data.expires_at ?? 0 }
141
+ }
@@ -0,0 +1,192 @@
1
+ // PTB builders for deploying a coin.
2
+ //
3
+ // Two transactions, because the OTW `init()` creates the TreasuryCap/MetadataCap
4
+ // and sends them to the sender — they are NOT results of `tx.publish`, so they
5
+ // can only be used after the publish executes:
6
+ //
7
+ // 1. buildPublishTransaction — publish the patched module, take the trivial
8
+ // fee, and apply the UpgradeCap policy (make immutable, or keep). The
9
+ // TreasuryCap + MetadataCap land with the sender automatically.
10
+ // 2. buildFinalizeTransaction — (optional) mint the initial supply, apply the
11
+ // supply + metadata policies, promote the currency in the registry, and move
12
+ // the caps/supply to the recipient. Only needed when any of those differ
13
+ // from the defaults.
14
+
15
+ import { Transaction } from '@mysten/sui/transactions'
16
+ import { toBase64 } from '@mysten/sui/utils'
17
+ import type { Network, PublishResult, TokenConfig } from './types.js'
18
+
19
+ const SUI_FRAMEWORK = '0x2'
20
+ const MOVE_STDLIB = '0x1'
21
+
22
+ export interface BuildPublishArgs {
23
+ moduleBytes: Uint8Array
24
+ sender: string
25
+ feeMist: bigint
26
+ feeRecipient: string
27
+ gasBudget: bigint
28
+ packagePolicy: TokenConfig['packagePolicy']
29
+ }
30
+
31
+ /**
32
+ * Publish PTB: publish -> (make_immutable | transfer UpgradeCap) + fee split.
33
+ * The user signs this; they pay gas and the fee.
34
+ */
35
+ export function buildPublishTransaction(args: BuildPublishArgs): Transaction {
36
+ const tx = new Transaction()
37
+ tx.setSender(args.sender)
38
+
39
+ const [upgradeCap] = tx.publish({
40
+ modules: [toBase64(args.moduleBytes)],
41
+ dependencies: [MOVE_STDLIB, SUI_FRAMEWORK],
42
+ })
43
+
44
+ if (args.packagePolicy === 'immutable') {
45
+ tx.moveCall({ target: `${SUI_FRAMEWORK}::package::make_immutable`, arguments: [upgradeCap] })
46
+ } else {
47
+ tx.transferObjects([upgradeCap], args.sender)
48
+ }
49
+
50
+ // Trivial fee: split from the gas coin into the operator treasury.
51
+ if (args.feeMist > 0n) {
52
+ const [fee] = tx.splitCoins(tx.gas, [args.feeMist])
53
+ tx.transferObjects([fee], args.feeRecipient)
54
+ }
55
+
56
+ tx.setGasBudget(args.gasBudget)
57
+ return tx
58
+ }
59
+
60
+ export interface BuildFinalizeArgs {
61
+ config: TokenConfig
62
+ coinType: string
63
+ treasuryCapId: string
64
+ metadataCapId: string
65
+ /**
66
+ * Full object reference for the pending Currency<T> object (at address 0xc).
67
+ * When provided, `finalize_registration` is called first in the PTB, promoting the
68
+ * pending Currency to a shared, RPC-discoverable object so wallets can read the
69
+ * coin's decimal metadata via suix_getCoinMetadata.
70
+ */
71
+ currencyRef?: { objectId: string; version: string; digest: string }
72
+ sender: string
73
+ gasBudget: bigint
74
+ }
75
+
76
+ /** Whether a finalize transaction is needed at all for this config. */
77
+ export function needsFinalize(config: TokenConfig, sender: string): boolean {
78
+ return (
79
+ config.initialSupply > 0n ||
80
+ config.supplyPolicy === 'fixed' ||
81
+ config.metadataPolicy === 'frozen' ||
82
+ config.recipient.toLowerCase() !== sender.toLowerCase()
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Finalize PTB: register the coin in the registry, mint initial supply, apply
88
+ * supply/metadata policy, and route caps to the recipient.
89
+ * Built after the publish effects reveal the cap object IDs.
90
+ */
91
+ export function buildFinalizeTransaction(args: BuildFinalizeArgs): Transaction {
92
+ const { config, coinType } = args
93
+ const tx = new Transaction()
94
+ tx.setSender(args.sender)
95
+ const recipient = config.recipient || args.sender
96
+
97
+ // Always call finalize_registration when we have the full object ref for the pending
98
+ // Currency<T>. This promotes it from a pending owned object (at @0xc) to a shared,
99
+ // RPC-discoverable object so wallets can read decimals via suix_getCoinMetadata.
100
+ // Without this step wallets fall back to 0 decimals and display raw base-unit counts.
101
+ if (args.currencyRef) {
102
+ tx.moveCall({
103
+ target: `${SUI_FRAMEWORK}::coin_registry::finalize_registration`,
104
+ typeArguments: [coinType],
105
+ arguments: [
106
+ tx.object('0xc'), // &mut CoinRegistry (shared object at well-known address)
107
+ tx.receivingRef(args.currencyRef), // Receiving<Currency<T>> — pending object at @0xc
108
+ ],
109
+ })
110
+ }
111
+
112
+ // Mint the initial supply (scaled by decimals) to the recipient.
113
+ if (config.initialSupply > 0n) {
114
+ const amount = config.initialSupply * 10n ** BigInt(config.decimals)
115
+ const minted = tx.moveCall({
116
+ target: `${SUI_FRAMEWORK}::coin::mint`,
117
+ typeArguments: [coinType],
118
+ arguments: [tx.object(args.treasuryCapId), tx.pure.u64(amount)],
119
+ })
120
+ tx.transferObjects([minted], recipient)
121
+ }
122
+
123
+ // Supply policy: fixed => freeze the TreasuryCap (no further minting possible);
124
+ // mintable => keep it with the recipient.
125
+ if (config.supplyPolicy === 'fixed') {
126
+ tx.moveCall({
127
+ target: `${SUI_FRAMEWORK}::transfer::public_freeze_object`,
128
+ typeArguments: [`${SUI_FRAMEWORK}::coin::TreasuryCap<${coinType}>`],
129
+ arguments: [tx.object(args.treasuryCapId)],
130
+ })
131
+ } else if (recipient.toLowerCase() !== args.sender.toLowerCase()) {
132
+ tx.transferObjects([tx.object(args.treasuryCapId)], recipient)
133
+ }
134
+
135
+ // Metadata policy: frozen => freeze the MetadataCap (no further metadata edits);
136
+ // updatable => keep it with the recipient.
137
+ if (config.metadataPolicy === 'frozen') {
138
+ tx.moveCall({
139
+ target: `${SUI_FRAMEWORK}::transfer::public_freeze_object`,
140
+ typeArguments: [`${SUI_FRAMEWORK}::coin_registry::MetadataCap<${coinType}>`],
141
+ arguments: [tx.object(args.metadataCapId)],
142
+ })
143
+ } else if (recipient.toLowerCase() !== args.sender.toLowerCase()) {
144
+ tx.transferObjects([tx.object(args.metadataCapId)], recipient)
145
+ }
146
+
147
+ tx.setGasBudget(args.gasBudget)
148
+ return tx
149
+ }
150
+
151
+ interface ObjectChange {
152
+ type: string
153
+ objectType?: string
154
+ objectId?: string
155
+ packageId?: string
156
+ /** Version and digest are present for created/mutated objects in the full RPC response. */
157
+ version?: string
158
+ digest?: string
159
+ }
160
+
161
+ /** Pull package id, coin type and cap ids out of publish `objectChanges`. */
162
+ export function extractPublishResult(
163
+ objectChanges: ObjectChange[],
164
+ ctx: { network: Network; digest: string; feeRecipient: string; feeMist: bigint },
165
+ ): PublishResult {
166
+ const published = objectChanges.find((c) => c.type === 'published')
167
+ const packageId = published?.packageId ?? ''
168
+
169
+ const createdOfType = (needle: string) =>
170
+ objectChanges.find((c) => c.type === 'created' && (c.objectType ?? '').includes(needle))
171
+
172
+ const treasury = createdOfType('coin::TreasuryCap')
173
+ const coinTypeMatch = treasury?.objectType?.match(/TreasuryCap<(.+)>/)
174
+ const coinType = coinTypeMatch?.[1] ?? ''
175
+
176
+ const currency = createdOfType('coin_registry::Currency')
177
+
178
+ return {
179
+ network: ctx.network,
180
+ packageId,
181
+ coinType,
182
+ treasuryCapId: treasury?.objectId,
183
+ metadataCapId: createdOfType('coin_registry::MetadataCap')?.objectId,
184
+ currencyId: currency?.objectId,
185
+ currencyVersion: currency?.version,
186
+ currencyDigest: currency?.digest,
187
+ upgradeCapId: createdOfType('package::UpgradeCap')?.objectId,
188
+ digest: ctx.digest,
189
+ feeRecipient: ctx.feeRecipient,
190
+ feeMist: ctx.feeMist.toString(),
191
+ }
192
+ }
@@ -0,0 +1,168 @@
1
+ // Deployment orchestration: patch -> publish -> wait -> (finalize) -> result.
2
+ // Decoupled from the wallet via an `Executor` so it is unit-testable.
3
+
4
+ import type { Transaction } from '@mysten/sui/transactions'
5
+ import { patchTemplateModule } from './template.js'
6
+ import {
7
+ buildPublishTransaction,
8
+ buildFinalizeTransaction,
9
+ extractPublishResult,
10
+ } from './buildPublishTx.js'
11
+ import { deriveStructName } from './validation.js'
12
+ import type { Network, PublishResult, TokenConfig } from './types.js'
13
+
14
+ export interface SuiTxResult {
15
+ digest: string
16
+ objectChanges?: {
17
+ type: string
18
+ objectType?: string
19
+ objectId?: string
20
+ packageId?: string
21
+ /** Present for created/mutated objects in the full Sui RPC response. */
22
+ version?: string
23
+ digest?: string
24
+ }[]
25
+ effects?: { status?: { status?: string; error?: string } }
26
+ }
27
+
28
+ export interface Executor {
29
+ /** Sign with the wallet and execute; must request object changes + effects. */
30
+ signAndExecute(tx: Transaction): Promise<SuiTxResult>
31
+ /** Block until the digest is indexed (so new objects resolve). */
32
+ waitForTransaction(digest: string): Promise<void>
33
+ }
34
+
35
+ export type DeployStep =
36
+ | 'patching'
37
+ | 'publishing'
38
+ | 'confirming'
39
+ | 'finalizing'
40
+ | 'confirming-finalize'
41
+ | 'done'
42
+
43
+ export interface DeployArgs {
44
+ config: TokenConfig
45
+ network: Network
46
+ sender: string
47
+ feeMist: bigint
48
+ feeTreasury: string
49
+ gasBudget: bigint
50
+ executor: Executor
51
+ onStep?: (step: DeployStep) => void
52
+ }
53
+
54
+ function assertSuccess(res: SuiTxResult, what: string): void {
55
+ const status = res.effects?.status?.status
56
+ if (status && status !== 'success') {
57
+ throw new Error(`${what} failed: ${res.effects?.status?.error ?? status}`)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Deploy a token end-to-end. Returns the publish result once any required
63
+ * finalize transaction has also confirmed.
64
+ */
65
+ export async function deployToken(args: DeployArgs): Promise<PublishResult> {
66
+ const { config, executor, onStep } = args
67
+ const structName = deriveStructName(config.moduleName)
68
+ const normalizedConfig: TokenConfig = { ...config, structName }
69
+ const recipient = normalizedConfig.recipient || args.sender
70
+ const isE2e = import.meta.env.VITE_E2E === '1'
71
+
72
+ onStep?.('patching')
73
+ const moduleBytes = await patchTemplateModule({
74
+ moduleName: normalizedConfig.moduleName,
75
+ structName,
76
+ symbol: normalizedConfig.symbol,
77
+ name: normalizedConfig.name,
78
+ description: normalizedConfig.description,
79
+ iconUrl: normalizedConfig.iconUrl,
80
+ decimals: normalizedConfig.decimals,
81
+ })
82
+
83
+ onStep?.('publishing')
84
+ const publishTx = buildPublishTransaction({
85
+ moduleBytes,
86
+ sender: args.sender,
87
+ feeMist: args.feeMist,
88
+ feeRecipient: args.feeTreasury,
89
+ gasBudget: args.gasBudget,
90
+ packagePolicy: normalizedConfig.packagePolicy,
91
+ })
92
+ const pub = await (async () => {
93
+ try {
94
+ const result = await executor.signAndExecute(publishTx)
95
+ if (isE2e) console.log('[E2E Deploy]', 'Publish TX result:', result)
96
+ assertSuccess(result, 'Publish')
97
+
98
+ onStep?.('confirming')
99
+ try {
100
+ if (isE2e) console.log('[E2E Deploy]', 'Waiting for transaction digest:', result.digest)
101
+ await executor.waitForTransaction(result.digest)
102
+ if (isE2e) console.log('[E2E Deploy]', 'Transaction confirmed')
103
+ } catch (e) {
104
+ if (isE2e) console.error('[E2E Deploy]', 'waitForTransaction error:', e)
105
+ throw e
106
+ }
107
+
108
+ return result
109
+ } catch (e) {
110
+ if (isE2e) console.error('[E2E Deploy]', 'Publish TX error:', e)
111
+ throw e
112
+ }
113
+ })()
114
+
115
+ const result = extractPublishResult(pub.objectChanges ?? [], {
116
+ network: args.network,
117
+ digest: pub.digest,
118
+ feeRecipient: args.feeTreasury,
119
+ feeMist: args.feeMist,
120
+ })
121
+
122
+ // A successful publish must surface the package id + coin type in its object
123
+ // changes; if not, fail loudly rather than returning an empty result panel.
124
+ if (!result.packageId || !result.coinType) {
125
+ throw new Error(
126
+ 'Publish succeeded but the package id / coin type could not be read from the transaction effects.',
127
+ )
128
+ }
129
+
130
+ // Build the currency receiving ref for finalize_registration (requires version + digest
131
+ // from the publish objectChanges, present in production Sui RPC responses).
132
+ const currencyChange = (pub.objectChanges ?? []).find(
133
+ (c) => c.type === 'created' && (c.objectType ?? '').includes('coin_registry::Currency'),
134
+ )
135
+ const currencyRef =
136
+ currencyChange?.objectId && currencyChange?.version && currencyChange?.digest
137
+ ? {
138
+ objectId: currencyChange.objectId,
139
+ version: currencyChange.version,
140
+ digest: currencyChange.digest,
141
+ }
142
+ : undefined
143
+
144
+ // Always finalize: the finalize PTB calls coin_registry::finalize_registration,
145
+ // which is required to promote the pending Currency<T> to a shared, wallet-discoverable
146
+ // object. Without it, suix_getCoinMetadata returns null and wallets fall back to
147
+ // 0 decimals, displaying raw base-unit counts instead of whole-token amounts.
148
+ if (!result.treasuryCapId || !result.metadataCapId) {
149
+ throw new Error('Published, but could not locate the TreasuryCap/MetadataCap to finalize.')
150
+ }
151
+ onStep?.('finalizing')
152
+ const finalizeTx = buildFinalizeTransaction({
153
+ config: { ...normalizedConfig, recipient },
154
+ coinType: result.coinType,
155
+ treasuryCapId: result.treasuryCapId,
156
+ metadataCapId: result.metadataCapId,
157
+ currencyRef,
158
+ sender: args.sender,
159
+ gasBudget: args.gasBudget,
160
+ })
161
+ const fin = await executor.signAndExecute(finalizeTx)
162
+ assertSuccess(fin, 'Finalize')
163
+ onStep?.('confirming-finalize')
164
+ await executor.waitForTransaction(fin.digest)
165
+
166
+ onStep?.('done')
167
+ return result
168
+ }
@@ -0,0 +1,61 @@
1
+ // Builds a deploy Executor using the wallet-adapter's shared signing capability
2
+ // + SuiClient for execution (returns objectChanges required by extractPublishResult).
3
+ //
4
+ // The wallet-adapter's own Executor only returns { digest } which is not sufficient
5
+ // for the token deploy flow; we sign via the shared wallet connection but execute
6
+ // via SuiClient so we can request showEffects + showObjectChanges.
7
+
8
+ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
9
+ import { useWallet } from '@meddleware/wallet-adapter'
10
+ import type { Transaction } from '@mysten/sui/transactions'
11
+ import type { SuiTxResult, Executor } from './deploy.js'
12
+ import type { Network } from './types.js'
13
+ import { RPC_URLS } from '../config.js'
14
+
15
+ /**
16
+ * Build a deploy Executor bound to the currently connected wallet (via the
17
+ * wallet-adapter singleton) and the given network's RPC URL.
18
+ *
19
+ * The returned executor signs via `sui:signTransaction` and executes via
20
+ * `SuiClient.executeTransactionBlock` with `showEffects + showObjectChanges`
21
+ * so the caller can extract the published package ID, coin type, TreasuryCap, etc.
22
+ */
23
+ export async function buildDeployExecutor(network: Network): Promise<Executor> {
24
+ const { currentWallet, account } = useWallet()
25
+ const wallet = currentWallet.value
26
+ const acct = account.value
27
+ if (!wallet || !acct) throw new Error('Connect a wallet first.')
28
+
29
+ const suiClient = new SuiJsonRpcClient({ url: RPC_URLS[network], network })
30
+ const chain = `sui:${network}` as const
31
+
32
+ const signFeature = wallet.features['sui:signTransaction'] as
33
+ | {
34
+ signTransaction: (input: {
35
+ transaction: Transaction
36
+ account: typeof acct
37
+ chain: `sui:${string}`
38
+ }) => Promise<{ bytes: string; signature: string }>
39
+ }
40
+ | undefined
41
+ if (!signFeature) throw new Error('This wallet cannot sign transactions.')
42
+
43
+ return {
44
+ async signAndExecute(tx: Transaction): Promise<SuiTxResult> {
45
+ const { bytes, signature } = await signFeature.signTransaction({
46
+ transaction: tx,
47
+ account: acct,
48
+ chain,
49
+ })
50
+ const res = await suiClient.executeTransactionBlock({
51
+ transactionBlock: bytes,
52
+ signature,
53
+ options: { showEffects: true, showObjectChanges: true },
54
+ })
55
+ return res as unknown as SuiTxResult
56
+ },
57
+ async waitForTransaction(digest: string): Promise<void> {
58
+ await suiClient.waitForTransaction({ digest })
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Extract a human-readable message from an unknown thrown value.
3
+ *
4
+ * Wallet SDKs sometimes throw plain objects (e.g. `{ code: 4001, message: "…" }`)
5
+ * rather than Error instances. This handles those alongside real Error objects,
6
+ * nested `cause` fields, and arbitrary fallback via String().
7
+ */
8
+ export function extractErrorMessage(e: unknown): string {
9
+ if (typeof e === 'string') return e
10
+ if (e instanceof Error) {
11
+ // Some wallet errors wrap the real message in a cause
12
+ if (!e.message && e.cause != null) return extractErrorMessage(e.cause)
13
+ return e.message || e.toString()
14
+ }
15
+ if (typeof e === 'object' && e !== null) {
16
+ const obj = e as Record<string, unknown>
17
+ if (typeof obj.message === 'string' && obj.message) return obj.message
18
+ if (obj.cause != null) return extractErrorMessage(obj.cause)
19
+ }
20
+ return String(e)
21
+ }
@@ -0,0 +1,75 @@
1
+ // The reactive form model (all text inputs are strings) and its conversion into
2
+ // the strongly-typed TokenConfig used by the deploy + generate pipelines.
3
+
4
+ import type { MetadataPolicy, PackagePolicy, SupplyPolicy, TokenConfig } from './types.js'
5
+ import { deriveStructName, parseSupply } from './validation.js'
6
+ import { DEFAULT_LICENSE_ID } from './licenses.js'
7
+
8
+ /**
9
+ * The reactive form state. Every field is a string (as bound to inputs); it is
10
+ * converted to the strongly-typed {@link TokenConfig} by {@link toTokenConfig}.
11
+ */
12
+ export interface FormModel {
13
+ packageName: string
14
+ moduleName: string
15
+ symbol: string
16
+ name: string
17
+ description: string
18
+ iconUrl: string
19
+ decimals: string
20
+ initialSupply: string
21
+ supplyPolicy: SupplyPolicy
22
+ metadataPolicy: MetadataPolicy
23
+ packagePolicy: PackagePolicy
24
+ recipient: string
25
+ license: string
26
+ licenseName: string
27
+ packageDescription: string
28
+ projectName: string
29
+ }
30
+
31
+ /** A blank form with the recommended safe defaults pre-selected. */
32
+ export function emptyForm(): FormModel {
33
+ return {
34
+ packageName: '',
35
+ moduleName: '',
36
+ symbol: '',
37
+ name: '',
38
+ description: '',
39
+ iconUrl: '',
40
+ decimals: '9',
41
+ initialSupply: '',
42
+ supplyPolicy: 'mintable',
43
+ metadataPolicy: 'updatable',
44
+ packagePolicy: 'immutable',
45
+ recipient: '',
46
+ license: DEFAULT_LICENSE_ID,
47
+ licenseName: '',
48
+ packageDescription: '',
49
+ projectName: '',
50
+ }
51
+ }
52
+
53
+ /** Convert the form into a TokenConfig. `recipient` empty means the connected wallet. */
54
+ export function toTokenConfig(form: FormModel): TokenConfig {
55
+ const structName = deriveStructName(form.moduleName)
56
+ return {
57
+ packageName: form.packageName.trim(),
58
+ moduleName: form.moduleName.trim(),
59
+ structName,
60
+ symbol: form.symbol.trim(),
61
+ name: form.name.trim(),
62
+ description: form.description.trim(),
63
+ iconUrl: form.iconUrl.trim(),
64
+ decimals: Number(form.decimals),
65
+ initialSupply: parseSupply(form.initialSupply) ?? 0n,
66
+ supplyPolicy: form.supplyPolicy,
67
+ metadataPolicy: form.metadataPolicy,
68
+ packagePolicy: form.packagePolicy,
69
+ recipient: form.recipient.trim(),
70
+ license: form.license,
71
+ licenseName: form.licenseName || form.license,
72
+ packageDescription: form.packageDescription.trim() || `Official ${form.symbol} token.`,
73
+ projectName: form.projectName.trim() || form.name,
74
+ }
75
+ }