@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,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
|
+
}
|
package/src/lib/form.ts
ADDED
|
@@ -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
|
+
}
|