Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
51 changes: 43 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ runtime locally (via miniflare), so behaviour matches production.

## Commands

| Command | Description |
| -------------------- | ------------------------------------------------- |
| `npm run dev` | Start local dev server |
| `npm run build` | Production build |
| `npm run typecheck` | Type-check (generates CF + RR types first) |
| `npm run lint` | Run ESLint |
| `npm run format` | Run Prettier |
| `npm run cf-typegen` | Regenerate Cloudflare types from `wrangler.jsonc` |
| Command | Description |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `npm run dev` | Start local dev server |
| `npm run build` | Production build |
| `npm run typecheck` | Type-check (generates CF + RR types first) |
| `npm run lint` | Run ESLint |
| `npm run format` | Run Prettier |
| `npm run cf-typegen` | Regenerate Cloudflare types from `wrangler.jsonc` |
| `npm run generate:registry-client` | Regenerate `clients/registry-client` from the deployed Registry contract |

## Stack

Expand Down Expand Up @@ -63,6 +64,8 @@ app/
app.css # Global styles and design tokens
workers/
app.ts # Cloudflare Worker entry
clients/
registry-client/ # Generated Registry contract bindings (see below)
wrangler.jsonc # Cloudflare config (vars + env per network)
```

Expand All @@ -72,6 +75,38 @@ Data is fetched from the Stellar Registry Indexer API. The base URL is set per
environment via the `REGISTRY_API_URL` variable in `wrangler.jsonc`. All
client-side requests are proxied through `/api/*` to avoid CORS issues.

## Registry contract client

`app/lib/deploy.ts` (the "Deploy a contract using this Wasm" flow) talks to the
Registry contract itself through generated TypeScript bindings at
`clients/registry-client`, an npm workspace package (`registry-client`) — not
hand-written. It's checked in, so you don't need to regenerate it just to work
on the app.

Regenerate it after a Registry contract release with:

```bash
npm run generate:registry-client
```

This runs two steps under the hood:

1. `stellar registry download registry -o /tmp/registry.wasm --network testnet -s me`
— fetches the current `registry` Wasm (the Registry contract publishes itself
into the registry, channel `root`) via the
[`stellar-registry` CLI](https://github.com/stellar-registry/cli)
2. `stellar contract bindings typescript --wasm /tmp/registry.wasm --output-dir clients/registry-client --overwrite`
— regenerates the client package from that binary via
[`stellar-cli`](https://github.com/stellar/stellar-cli)

Requires both CLIs installed (`cargo install --locked stellar-registry-cli`,
`cargo install --locked stellar-cli` or equivalent) and a configured identity
for `-s`/`--source-account` (e.g.
`stellar keys generate me --network testnet --fund`) — `download` simulates a
read call, which still needs a source account. The contract's interface is the
same on testnet and mainnet (deployed deterministically, see
`app/lib/network.ts`), so generating from testnet is fine either way.

---

<div align="center">
Expand Down
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
109 changes: 109 additions & 0 deletions app/lib/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Builds, simulates, signs, and submits the `deploy_unnamed` invocation
// against the Registry contract, via the generated `registry-client`
// bindings (see clients/registry-client — regenerate with
// `npm run generate:registry-client`). Client-only — dynamically imports
// @stellar/stellar-sdk and registry-client so neither is ever 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
}

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 [{ nativeToScVal }, { Client: RegistryClient }] = await Promise.all([
import("@stellar/stellar-sdk"),
import("registry-client"),
])

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

const init = constructorArgs
? 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 })
})
: undefined
Comment thread
pselle marked this conversation as resolved.
Outdated

const registry = new RegistryClient({
contractId: registryContractId,
networkPassphrase,
rpcUrl,
allowHttp: true,
publicKey: deployerAddress,
// registry-client's ClientOptions expects the Freighter-shaped signer
// (xdr, opts) => Promise<{ signedTxXdr }>; wallet.ts's signTransaction is
// the simpler (xdr) => Promise<string> shape used throughout the deploy
// dialog, so adapt it here rather than changing that call site.
signTransaction: async (xdr) => ({
signedTxXdr: await signTransaction(xdr),
}),
})
Comment thread
pselle marked this conversation as resolved.
Outdated

let tx
try {
tx = await registry.deploy_unnamed({
wasm_name: wasmName,
version: wasmVersion,
init,
salt: Buffer.from(salt),
deployer: deployerAddress,
})
} catch (e) {
throw new Error(
`Failed to prepare the deploy transaction: ${e instanceof Error ? e.message : String(e)}`,
)
}

let sent
try {
sent = await tx.signAndSend()
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
// txBadAuth almost always means the connected wallet's account changed
// (in the extension, out of band) between building and submitting the
// transaction — surface that instead of the raw RPC failure JSON.
if (message.includes("txBadAuth")) {
throw new Error(
"The network rejected the transaction's signature (txBadAuth) — this usually means the connected wallet account changed. Disconnect and reconnect your wallet, then try deploying again.",
)
}
throw new Error(`Failed to send the deploy transaction: ${message}`)
}
Comment thread
pselle marked this conversation as resolved.
Outdated
// `result` is a Rust-style Result<Address, Error> — unwrap() throws the
// contract's own error message (e.g. "NoSuchWasmPublished") on failure.
const { result } = sent
return { contractId: result.unwrap() }
}
Loading