@solarpunkltd/smart-stellar-demo v0.0.1
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
- 💻 Stellar CLI - Featuring: Generating Bindings
- ⚙️ Stellar Javascript SDK - Featuring: Stellar RPC Server
- 🔐 Passkey Kit - Seamless authentication
- 🚀 Launchtube - Transaction submission and paymaster functionality
🔐 Passkey Kit: Simplifying UX
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 is a super cool service that abstracts away the complexity of submitting transactions.
Transaction Lifecycle Management:
- Transaction Submission
- Retries
- Working around rate limits
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:
- Stellar CLI
- Using Stellar Lab
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 structuredChatEvent
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
filter
s arrayfilters: []
- Import deployed contract ID from env
process.env.NEXT_PUBLIC_CHAT_CONTRACT_ID!
- Set
startLedger
orcursor
- Set
limit
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
andcontractId
is present - Get
Address
from first entry in event topic arrayevent.topic[0].address()
- Output as publicKey type
Ed25519
forscAddressTypeAccount
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 insrc/app/types/Utils.types.ts
- Set fields in
ChatEvent
: - id asstring
- addr asstring
- timestamp asDate
- txHash asstring
- msg asstring
// 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 eventsNEXT_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:
Command | Action |
---|---|
pnpm install | Installs dependencies |
pnpm dev | Starts 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 messagessrc/app/components/Header.tsx
- Used to login with Passkeyssrc/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 ChatEvent
s 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
andrpc
fromsrc/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
statesetMessages(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()
- Use React Hook to trigger call to rpc server
- https://react.dev/reference/react/useEffect
- Sync chat message data displayed in UI with emitted contract events
// 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
configuresClient
fromchat-demo-sdk
contract bindingschat-demo-sdk
bindings were generated with Stellar CLI - Reviewchat-demo-sdk/README.md
for more info- Client configured in
chat.ts
withrpcUrl
,contractId
andnetworkPassphrase
from.env
params - Invoke deployed contract
chat.send()
function, passing inaddr
andmsg
string - Sign
AssembledTransaction
withPasskeyKit
Signer passing inkeyId
and transaction tosign()
function - This will then prompt your browser to request your fingerprint
- Use the Launchtube
PasskeyServer
configured withrpcUrl
,launchtubeUrl
andlaunchtubeJwt
- 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.
2 months ago