Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ REGISTRY_API_URL=https://stellar-registry-testnet.fly.dev
# The Stellar network this instance targets. Used to label the UI.
# Options: testnet | mainnet
REGISTRY_NETWORK=testnet

# Soroban RPC endpoint used client-side to simulate/submit the "Deploy a
# contract using this Wasm" transaction.
REGISTRY_RPC_URL=https://soroban-testnet.stellar.org
101 changes: 101 additions & 0 deletions app/components/dialog.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
.overlay {
position: fixed;
inset: 0;
background-color: rgb(0 0 0 / 0.5);
z-index: 50;

&[data-state="open"] {
animation: overlayShow 150ms var(--default-transition-timing-function);
}
}

.content {
position: fixed;
top: 50%;
left: 50%;
translate: -50% -50%;
width: calc(100% - calc(var(--spacing) * 8));
max-width: 28rem;
max-height: 85vh;
overflow-y: auto;
background-color: var(--color-background);
color: var(--color-foreground);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
padding: calc(var(--spacing) * 6);
z-index: 51;

&[data-state="open"] {
animation: contentShow 150ms var(--default-transition-timing-function);
}
}

.close {
position: absolute;
top: calc(var(--spacing) * 4);
right: calc(var(--spacing) * 4);
display: flex;
align-items: center;
justify-content: center;
width: calc(var(--spacing) * 7);
height: calc(var(--spacing) * 7);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;

&:hover {
background-color: var(--color-accent);
color: var(--color-accent-foreground);
}
}

.header {
display: flex;
flex-direction: column;
gap: calc(var(--spacing) * 1);
margin-bottom: calc(var(--spacing) * 4);
padding-right: calc(var(--spacing) * 6);
}

.title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 500;
margin: 0;
}

.description {
font-size: var(--text-sm);
color: var(--color-muted-foreground);
margin: 0;
}

.footer {
display: flex;
justify-content: flex-end;
gap: calc(var(--spacing) * 2);
margin-top: calc(var(--spacing) * 6);
}

@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes contentShow {
from {
opacity: 0;
translate: -50% calc(-50% + 8px);
}
to {
opacity: 1;
translate: -50% -50%;
}
}
74 changes: 74 additions & 0 deletions app/components/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { type ComponentProps } from "react"
import styles from "./dialog.module.css"

const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger

function DialogContent({
className,
children,
...props
}: ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className={styles.overlay} />
<DialogPrimitive.Content
className={`${styles.content} ${className ?? ""}`.trim()}
{...props}
>
{children}
<DialogPrimitive.Close className={styles.close} aria-label="Close">
<X size={16} />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}

function DialogHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div className={`${styles.header} ${className ?? ""}`.trim()} {...props} />
)
}

function DialogTitle({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={`${styles.title} ${className ?? ""}`.trim()}
{...props}
/>
)
}

function DialogDescription({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={`${styles.description} ${className ?? ""}`.trim()}
{...props}
/>
)
}

function DialogFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div className={`${styles.footer} ${className ?? ""}`.trim()} {...props} />
)
}

export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
}
14 changes: 14 additions & 0 deletions app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
type SearchParams,
type Contract,
type ContractDetail,
type DeploySpec,
type ListResponse,
type Registry,
type Wasm,
type WasmDetail,
} from "./types"
Expand Down Expand Up @@ -95,6 +97,18 @@ export async function getWasm(
return apiFetch<WasmDetail>(path, apiUrl)
}

export async function getRegistries(apiUrl?: string): Promise<Registry[]> {
const data = await apiFetch<ListResponse<Registry>>("/registries", apiUrl)
return data.result
}

export async function getDeploySpec(
wasmHash: string,
apiUrl?: string,
): Promise<DeploySpec> {
return apiFetch<DeploySpec>(`/wasms/${wasmHash}/deploy-spec`, apiUrl)
}

export async function checkHealth(): Promise<boolean> {
try {
// NOTE: don't use `apiFetch` helper, only check response don't parse empty content
Expand Down
121 changes: 121 additions & 0 deletions app/lib/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Builds, simulates, signs, and submits the `deploy_unnamed` invocation
// against the Registry contract. Client-only — dynamically imports
// @stellar/stellar-sdk so it's never pulled into the SSR bundle.
//
// See stellar-registry/contracts registry::contract::deploy_unnamed:
// fn deploy_unnamed(wasm_name, version, init, salt, deployer) -> Address
// (deployer.require_auth() only, so any connected wallet can call it.)

import { type SupportedSpecType, parseArgValue } from "./scval"

export type ConstructorArg = {
name: string
type: SupportedSpecType
rawValue: string
}

const POLL_INTERVAL_MS = 1500
const POLL_TIMEOUT_MS = 30_000

export type DeployResult = { contractId: string }

export async function deployFromWasm({
rpcUrl,
networkPassphrase,
registryContractId,
wasmName,
wasmVersion,
constructorArgs,
deployerAddress,
signTransaction,
}: {
rpcUrl: string
networkPassphrase: string
registryContractId: string
wasmName: string
wasmVersion?: string
/** undefined = no constructor to call (init passed as void) */
constructorArgs: ConstructorArg[] | undefined
deployerAddress: string
signTransaction: (xdr: string) => Promise<string>
}): Promise<DeployResult> {
const {
rpc,
TransactionBuilder,
Operation,
BASE_FEE,
nativeToScVal,
scValToNative,
xdr,
} = await import("@stellar/stellar-sdk")

const server = new rpc.Server(rpcUrl)

const salt = crypto.getRandomValues(new Uint8Array(32))

const initArg = constructorArgs
? xdr.ScVal.scvVec(
constructorArgs.map((arg) => {
const value = parseArgValue(arg.type, arg.rawValue)
// "bool" isn't a valid nativeToScVal type hint — a JS boolean
// converts to scvBool unambiguously without one.
return arg.type === "bool"
? nativeToScVal(value)
: nativeToScVal(value, { type: arg.type })
}),
)
: xdr.ScVal.scvVoid()

const args = [
nativeToScVal(wasmName, { type: "string" }),
wasmVersion
? nativeToScVal(wasmVersion, { type: "string" })
: xdr.ScVal.scvVoid(),
initArg,
nativeToScVal(salt, { type: "bytes" }),
nativeToScVal(deployerAddress, { type: "address" }),
]

const account = await server.getAccount(deployerAddress)
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase,
})
.addOperation(
Operation.invokeContractFunction({
contract: registryContractId,
function: "deploy_unnamed",
args,
}),
)
.setTimeout(60)
.build()

const prepared = await server.prepareTransaction(tx)
const signedXdr = await signTransaction(prepared.toXDR())
const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase)

const sent = await server.sendTransaction(signedTx)
if (sent.status !== "PENDING") {
throw new Error(`Failed to submit transaction: ${sent.status}`)
}

const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
const result = await server.getTransaction(sent.hash)
if (result.status === "SUCCESS") {
if (!result.returnValue) {
throw new Error("Deploy succeeded but returned no contract address.")
}
return { contractId: scValToNative(result.returnValue) as string }
}
if (result.status === "FAILED") {
throw new Error("Deploy transaction failed on-chain.")
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
}

throw new Error(
"Timed out waiting for the deploy transaction to confirm. Check the transaction hash on stellar.expert.",
)
Comment thread
pselle marked this conversation as resolved.
Outdated
}
25 changes: 25 additions & 0 deletions app/lib/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Stellar network constants for the `network` label already threaded through
// the app via `useRootData()` (see app/root.tsx). These are protocol/registry
// constants, not operational config — unlike REGISTRY_API_URL/REGISTRY_RPC_URL
// there's nothing to configure per-deploy, so they live in code.

const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"
const MAINNET_PASSPHRASE = "Public Global Stellar Network ; September 2015"

export function networkPassphrase(network: string) {
return network === "mainnet" ? MAINNET_PASSPHRASE : TESTNET_PASSPHRASE
}

// The Stellar Registry contract's own address on each network. Deployed
// deterministically (fixed salt) by stellar-registry/contracts, so these are
// stable — see indexer/goldsky/networks/{testnet,mainnet}.env `ROOT_REGISTRY`.
const TESTNET_REGISTRY_CONTRACT_ID =
"CAAXJETKPYAATU4HVVQUTE2FFBULNFGZNEOC3MS635U5K3GZLAY2HI4M"
const MAINNET_REGISTRY_CONTRACT_ID =
"CDU4M3LDIOUJJ5F3YXKJ4EJEP5VPRPG6N2LJ5HOQIMN7MNGL3NS3EGUY"

export function registryContractId(network: string) {
return network === "mainnet"
? MAINNET_REGISTRY_CONTRACT_ID
: TESTNET_REGISTRY_CONTRACT_ID
}
16 changes: 16 additions & 0 deletions app/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { keepPreviousData, queryOptions } from "@tanstack/react-query"
import {
getContract,
getContracts,
getDeploySpec,
getRegistries,
getWasm,
getWasmMeta,
getWasms,
Expand Down Expand Up @@ -46,3 +48,17 @@ export const wasmMetaQueryOptions = (repoUrl: string) =>
staleTime: STALE_TIME,
queryFn: () => getWasmMeta(repoUrl),
})

export const deploySpecQueryOptions = (wasmHash: string) =>
queryOptions({
queryKey: ["deploy-spec", wasmHash],
staleTime: STALE_TIME,
queryFn: () => getDeploySpec(wasmHash),
})

export const registriesQueryOptions = () =>
queryOptions({
queryKey: ["registries"],
staleTime: STALE_TIME,
queryFn: () => getRegistries(),
})
Loading