0.0.1 • Published 2 months ago

@solarpunkltd/smart-stellar-demo v0.0.1

Weekly downloads
-
License
-
Repository
-
Last release
2 months ago

Stellar Smart Contract Demo: React & Next.js Front-end

Learn how to build a dApp front-end on the ✨ Stellar Network with smart wallets powered by Stellar Dev Tools like the Stellar CLI, the Stellar Javascript SDK, Passkey Kit and Launchtube.

With a front-end built with React and Next.js with Zustand for state management.

This example builds on the contract and UI from kalepail/smart-stellar-demo

🛠️ Dev Tools


🔐 Passkey Kit: Simplifying UX

Passkey Kit GitHub Repository

Self-custody is too complicated for users.

Passkey Kit streamlines user experience leveraging biometric authentication for signing and fine-grained authorization of Stellar transactions with Policy Signers.


🚀 Launchtube: Get your Operation On-Chain

Launchtube GitHub Repository

Launchtube is a super cool service that abstracts away the complexity of submitting transactions.

  1. Transaction Lifecycle Management:

    • Transaction Submission
    • Retries
    • Working around rate limits
  2. Paymaster Service:

    • Pays transaction fees

✨ Stellar Smart Contract React & Next.js Demo

Secure, passkey-powered, chat messages.

Polling for Events

Make a Remote Procedure Call(RPC) with:

Using a start-ledger parameter:

stellar events \
	--network testnet \
    --start-ledger 589386 \
    --id CBUMOJAEAPLQUCWVIM6HJH5XKXW5OP7CRVOOYMJYSTZ6GFDNA72O2QW6 \
    --output pretty

Using Stellar Lab Stellar lab getEvents request

Understanding the getEvents() RPC Response

JSON response for Get Events RPC Call:

{
  "jsonrpc": "2.0",
  "id": 8675309,
  "result": {
	"events": [
	  {
		"type": "contract",
		"ledger": 589387,
		"ledgerClosedAt": "2025-04-22T20:52:41Z",
		"contractId": "CBUMOJAEAPLQUCWVIM6HJH5XKXW5OP7CRVOOYMJYSTZ6GFDNA72O2QW6",
		"id": "0002531397889695744-0000000001",
		"pagingToken": "0002531397889695744-0000000001",
		"inSuccessfulContractCall": true,
		"txHash": "86ad86ba26466e50b764cb7c0dab1082a5e1eec4e1cc82ae2bade7fbeb5d143f",
		"topic": [
		  "AAAAEgAAAAAAAAAAxJYJmGjzotfUZImIspIV+7UI2gWeEsNcIDRS4CIg2FE="
		],
		"value": "AAAADgAAABB0ZXN0LW1zZy10by1zZW5k"
	  }
	],
	"latestLedger": 589890,
	"cursor": "0002533562553204735-4294967295"
  }
}

Topic Field: ScVal representing the Address: Path: result.events.topic

AAAAEgAAAAAAAAAAxJYJmGjzotfUZImIspIV+7UI2gWeEsNcIDRS4CIg2FE=

Decoded Event Topic: ScVal JSON representing an Address:

{
  "address": "GDCJMCMYNDZ2FV6UMSEYRMUSCX53KCG2AWPBFQ24EA2FFYBCEDMFCBCV"
}

XDR Value Field: ScVal representing the message payload: Path: result.events.value

AAAADgAAABB0ZXN0LW1zZy10by1zZW5k

Decoded JSON:

{
  "string": "test-msg-to-send"
}

Rpc Server - Retrieve and Process Contract Events

Path: src/app/utils/rpc.ts Uses the Stellar Javascript SDK

Contract Event Retrieval:

  • Fetches contract events
  • Filters events by contract ID, topic and validates data integrity
  • Converts Api.GetEventsResponse into structured ChatEvent objects
  • ChatEvent type defined in src/app/types/Utils.types.ts

Fetch Contract Events:

  • Instantiate RPC Server
export const rpc = new Server(process.env.NEXT_PUBLIC_RPC_URL!);
  • Call Get Events RPC call
export async function getEvents(msgs: ChatEvent[], limit: number | string, found: boolean = false) {
    await rpc.getEvents ({});
}

Filter Events by Contract ID:

Get events for deployed contract using contract filter.

  • Pass in contract filters array filters: []
  • Import deployed contract ID from env process.env.NEXT_PUBLIC_CHAT_CONTRACT_ID!
  • Set startLedger or cursor
  • Setlimit or max returned values
await rpc.getEvents(
    {
		// Set events filters
		filters: [
            // Filter for contract events of deployed contract
			{
				type: "contract",
				contractIds: [process.env.NEXT_PUBLIC_CHAT_CONTRACT_ID!],
			},
		],
		// Ledger # to start return events from
		startLedger: typeof limit === "number" ? limit : undefined,
		// Max number of returned entries
		limit: 10_000,
		// Cursor to start looking at events from
		cursor: typeof limit === "string" ? limit : undefined,
	}
)

Convert from GetEvent API Response to Chat Event Object:

Take raw RPC GetEvent responses, validate and convert data for local UI usage.

  • Validate event type is contract and contractId is present
  • Get Address from first entry in event topic array event.topic[0].address()
  • Output as publicKey type Ed25519 for scAddressTypeAccount type
  • Or contractId for scAddressTypeContract type
// Loop through events return by `getEvents()` rpc call
events.forEach((event) => {
    // Verify event type and contractId
	if (event.type !== "contract" || !event.contractId) return;

	if (msgs.findIndex(({ id }) => id === event.id) === -1) {
		let addr: string | undefined;
        // Get Address from first entry in event topic array
		const topic0 = event.topic[0].address();

		switch (topic0.switch().name) {
            // Return ed25519 accountId string 
			case "scAddressTypeAccount": {
				addr = Address.account(
						topic0.accountId().ed25519(),
				).toString();
				break;
			}
            // Return contractId address string
			case "scAddressTypeContract": {
				addr = Address.contract(
						topic0.contractId(),
				).toString();
				break;
			}
		}
	}
});

Create ChatEvent from RPC Contract Event data

Take extracted fields from raw getEvents() response and create Chat Event.

  • ChatEvent interface defined in src/app/types/Utils.types.ts
  • Set fields in ChatEvent: - id as string - addr as string - timestamp as Date - txHash as string - msg as string
// Add to msgs array
msgs.push(
    // Create `ChatEvent` from extracted data from event
    {
    	id: event.id,
    	addr,
    	timestamp: new Date(event.ledgerClosedAt),
    	txHash: event.txHash,
    	msg: scValToNative(event.value),
	} as ChatEvent
);

Configuration Options

  • _limit: Maximum number of events to retrieve per request (default: 1,000)
  • Environment variables from .env: - NEXT_PUBLIC_RPC_URL: The URL of the Stellar RPC server - NEXT_PUBLIC_NETWORK_PASSPHRASE: The network passphrase for the target Stellar network - NEXT_PUBLIC_CHAT_CONTRACT_ID: The default contract ID to filter events
    • NEXT_PUBLIC_CHAT_CONTRACT_START_LEDGER : Stat ledger for RPC call

Front-End UI Code

Running the next.js/React project with pnpm

🛠 PNPM Commands

Run from the root directory of the project:

CommandAction
pnpm installInstalls dependencies
pnpm devStarts local dev server at localhost:3000

Now let's review the 3 React components Form, Header and Welcome.

  • src/app/components/Form.tsx - Used to submit Chat messages
  • src/app/components/Header.tsx - Used to login with Passkeys
  • src/app/components/Welcome.tsx - Used to display Chat messages

React Component Header.tsx

Facilitates creation of passkey accounts with Passkey's kit.

Review the following file: src/app/components/Header.tsx

async function signUp() {
    // Set icon
	setCreating(true);

	try {
		// PasskeyKit initialized in src/app/utils/passkey-kit.ts used to create smart wallet
		const {
			keyIdBase64,
			contractId: cid,
			signedTx,
		} = await account.createWallet("Smart Stellar Demo", "Smart Stellar Demo User");

        // Launchtube server initialized in src/app/utils/passkey-kit.ts
		await server.send(signedTx);

        // Store passkey keyId in local storage
		updateKeyId(keyIdBase64);
		localStorage.setItem("ssd:keyId", keyIdBase64);

		updateContractId(cid)
	} finally {
		setCreating(false);
	}
}

React Component Welcome.tsx

Let's walk through how ChatEvents are displayed in the UI.

Review the following file: src/app/components/Welcome.tsx

This component prints out the chat messages stored in state:

  • Setup and configure state management
  • Import getEvents and rpc from src/app/utils/rpc.ts
  • Setup useEffect() React hook to schedule state update event
  • Use rpc.getLatestLedger() to create 24 lookback period
  • Call getEvents() rpc function
  • Merge chat messages into existing array: mergedArray = [...messages, ...msgs]
  • De-duplicate messages using map with unique keys
  • Sort messages by Timestamp
  • Set messages in zustand state setMessages(messages => {})

Configure State

Messages are stored in state as an array in zustand:

	// Setup and configure state management
    const [messages, setMessages] = useState([{
	id: "",
	addr: "",
	timestamp: new Date,
	txHash: "",
	msg: "",
}]);

Set React Hook: useEffect()

// Setup React hook
useEffect(() => {
	async function eventInterval() {

        // Determine ledger sequence for 24 hour lookback period
		const { sequence } = await rpc.getLatestLedger();
		await callGetEvents(sequence - 17_280); // last 24 hrs

		// Setup interval for updating state with rpc getEvents() call
		interval = setInterval(async () => {
			const { sequence } = await rpc.getLatestLedger();
			await callGetEvents(sequence - 17_280); // last 24 hrs
			console.log('timer');
		}, 12_000); // 12_000 = 5 times per minute
	}
	eventInterval();

	return () => {
		if (interval) clearInterval(interval);
	};
}, []);

Update State with Sorted Unique Map of Messages

  • Get Events
  • Merge chat & de-dupe messages
  • Sort by Timestamp then set messages state
// Function to update state with rpc getEvents() call
async function callGetEvents(
		limit: number | string,
		found: boolean = false,
) {
    // Call getEvents() rpc function
	msgs = await getEvents(msgs, limit, found);

    // Update zustand state with sorted, unique map of messages
	setMessages(messages => {
		const mergedArray = [...messages, ...msgs];
		const uniqueMap = new Map();
		const key = 'id';

		for (const item of mergedArray) {
			uniqueMap.set(item[key], item);
		}

        // Sort messages by timestamp
		const sortedUniqueMap = Array.from(uniqueMap.values()).sort(
				(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
		);

		return sortedUniqueMap;
	});
}

Updating the UI

Updating the UI in response to changes in the state.

Loop through msgs array and display ChatEvent in UI:

  • Use React JSX expression language
  • Use messages.map to iterate over messages stored in state
  • Print out ChatEvent message fields embedded in styled HTML
  • Each message is enclosed in a unordered list elements <li>
<ul>
	{messages.map((msg, i) => (
	<li className="mb-2" key={i}>
		<span className="text-mono text-sm bg-black rounded-t-lg text-white px-3 py-1">
			
			<a className="underline"
			   target="_blank"
			   href={"https://stellar.expert/explorer/public/tx/" + msg.txHash}>
				{truncate(msg.addr, 4)}
            </a>
			
			<time className="text-xs text-gray-400">
				{msg.timestamp.toLocaleTimeString()}
            </time>
        </span>
		<p className="min-w-[220px] text-pretty break-words bg-gray-200 px-3 py-1 rounded-b-lg rounded-tr-lg border border-gray-400">
			{msg.msg}
		</p>
	</li>
	))}
</ul>

React Component Form.tsx

Let's walk through how the MessageForm component is used to send messages

Review the following file: src/app/components/Form.tsx

  • src/app/utils/chat.ts configures Client from chat-demo-sdk contract bindings
  • chat-demo-sdk bindings were generated with Stellar CLI - Review chat-demo-sdk/README.md for more info
  • Client configured in chat.ts with rpcUrl, contractId and networkPassphrase from .env params
  • Invoke deployed contract chat.send() function, passing in addr and msg string
  • Sign AssembledTransaction with PasskeyKit Signer passing in keyId and transaction to sign() function
  • This will then prompt your browser to request your fingerprint
  • Use the Launchtube PasskeyServer configured with rpcUrl, launchtubeUrl and launchtubeJwt
  • Await JSON response from Launchtube server
// Define onSubmit action for React Form
async function onSubmit(event: FormEvent<HTMLFormElement>) {
    
    // Get form data from React FormEvent data
	let msg = formData.get('msg') as string;
	const formKeyId = formData.get('kid') as string;
	const formContractId = formData.get('cid') as string;

    // Assemble chat.send() contract function invocation with client contract bindings
    let at = await chat.send(
        {
			addr: formContractId,
			msg,
		});

    	// Sign assembled transaction with Passkey Kit
		at = await account.sign(at, { keyId: formKeyId });

        // Send transaction with Launchtube
		await server.send(at);
}

For more details on how Passkeys and Launchtube work check out the example repo: https://github.com/kalepail/smart-stellar-demo

👀 Want to learn more?

Feel free to check our documentation or jump into our Discord server.