Skip to content

Commit 098ee9e

Browse files
committed
fix: update frontend tutorial to latest libs
And just make sure it works, in general. It was pretty broken. Relies on these fixes: - stellar/soroban-template-astro#13 - stellar/soroban-template-astro#14 And can be cleaned up once this is merged: - Creit-Tech/Stellar-Wallets-Kit#49
1 parent 6de2e44 commit 098ee9e

File tree

1 file changed

+136
-87
lines changed

1 file changed

+136
-87
lines changed

docs/build/apps/dapp-frontend.mdx

Lines changed: 136 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,32 @@ Let's get started.
1919

2020
You're going to need [Node.js](https://nodejs.org/en/download/package-manager/) v18.14.1 or greater. If you haven't yet, install it now.
2121

22-
We want to initialize our current project as an Astro project. To do this, we can again turn to the `stellar contract init` command, which has a `--frontend-template` flag that allows us to pass the url of a frontend template repository. As we learned in [Storing Data](../smart-contracts/getting-started/storing-data.mdx#adding-the-increment-contract), `stellar contract init` will not overwrite existing files, and is safe to use to add to an existing project.
22+
We want to initialize our current project as an Astro project. To do this, we can clone a template. You can find Soroban templates on GitHub by [searching for repositories that start with "soroban-template-"](https://github.com/search?q=%22soroban-template-%22&type=repositories). For this tutorial, we'll use [stellar/soroban-template-astro](https://github.com/stellar/soroban-template-astro). We'll also use a tool called [degit](https://github.com/Rich-Harris/degit) to clone the template without its git history. This will allow us to set it up as our own git project.
2323

24-
From our `soroban-hello-world` directory, run the following command to add the Astro template files.
24+
Since you have `node` and its package manager `npm` installed, you also have `npx`. Make sure you're no longer in your `soroban-hello-world` directory and then run:
2525

26-
```sh
27-
stellar contract init ./ \
28-
--frontend-template https://github.com/stellar/soroban-astro-template
26+
```
27+
npx degit stellar/soroban-template-astro first-soroban-app
28+
cd first-soroban-app
29+
git init
30+
git add .
31+
git commit -m "first commit: initialize from stellar/soroban-template-astro"
2932
```
3033

31-
This will add the following to your project, which we'll go over in more detail below.
34+
This project has the following directory structure, which we'll go over in more detail below.
3235

3336
```bash
37+
├── contracts
38+
│   ├── hello_world
39+
│   └── increment
3440
├── CONTRIBUTING.md
41+
├── Cargo.toml
42+
├── Cargo.lock
3543
├── initialize.js
3644
├── package-lock.json
3745
├── package.json
3846
├── packages
3947
├── public
40-
│   └── favicon.svg
4148
├── src
4249
│   ├── components
4350
│   │   └── Card.astro
@@ -49,6 +56,8 @@ This will add the following to your project, which we'll go over in more detail
4956
└── tsconfig.json
5057
```
5158

59+
The `contracts` are the same ones you walked through in the previous steps of the tutorial.
60+
5261
## Generate an NPM package for the Hello World contract
5362

5463
Before we open the new frontend files, let's generate an NPM package for the Hello World contract. This is our suggested way to interact with contracts from frontends. These generated libraries work with any JavaScript project (not a specific UI like React), and make it easy to work with some of the trickiest bits of Soroban, like encoding [XDR](../../learn/encyclopedia/contract-development/types/fully-typed-contracts.mdx).
@@ -91,29 +100,26 @@ Let's take a look at the contents of the `.env` file:
91100

92101
```
93102
# Prefix with "PUBLIC_" to make available in Astro frontend files
94-
PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
95-
PUBLIC_SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"
103+
PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
104+
PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"
96105
97-
SOROBAN_ACCOUNT="me"
98-
SOROBAN_NETWORK="standalone"
99-
100-
# env vars that begin with PUBLIC_ will be available to the client
101-
PUBLIC_SOROBAN_RPC_URL=$SOROBAN_RPC_URL
106+
STELLAR_ACCOUNT="me"
107+
STELLAR_NETWORK="standalone"
102108
```
103109

104110
This `.env` file defaults to connecting to a locally running network, but we want to configure our project to communicate with Testnet, since that is where we deployed our contracts. To do that, let's update the `.env` file to look like this:
105111

106112
```diff
107113
# Prefix with "PUBLIC_" to make available in Astro frontend files
108-
-PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
109-
+PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
110-
-PUBLIC_SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"
111-
+PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org:443"
112-
113-
-SOROBAN_ACCOUNT="me"
114-
+SOROBAN_ACCOUNT="alice"
115-
-SOROBAN_NETWORK="standalone"
116-
+SOROBAN_NETWORK="testnet"
114+
-PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
115+
+PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
116+
-PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"
117+
+PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443"
118+
119+
-STELLAR_ACCOUNT="me"
120+
+STELLAR_ACCOUNT="alice"
121+
-STELLAR_NETWORK="standalone"
122+
+STELLAR_NETWORK="testnet"
117123
```
118124

119125
:::info
@@ -228,52 +234,87 @@ import {
228234
allowAllModules,
229235
FREIGHTER_ID,
230236
StellarWalletsKit,
231-
WalletNetwork,
232237
} from "@creit.tech/stellar-wallets-kit";
233238

234-
const kit: StellarWalletsKit = new StellarWalletsKit({
239+
const SELECTED_WALLET_ID = "selectedWalletId";
240+
241+
function getSelectedWalletId() {
242+
return localStorage.getItem(SELECTED_WALLET_ID);
243+
}
244+
245+
const kit = new StellarWalletsKit({
235246
modules: allowAllModules(),
236-
network: WalletNetwork.TESTNET,
237-
selectedWalletId: FREIGHTER_ID,
247+
network: import.meta.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE,
248+
// StellarWalletsKit forces you to specify a wallet, even if the user didn't
249+
// select one yet, so we default to Freighter.
250+
// We'll work around this later in `getPublicKey`.
251+
selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID,
238252
});
239253

240-
const connectionState: { publicKey: string | undefined } = {
241-
publicKey: undefined,
242-
};
254+
export const signTransaction = kit.signTransaction.bind(kit);
255+
256+
export async function getPublicKey() {
257+
if (!getSelectedWalletId()) return null;
258+
const { address } = await kit.getAddress();
259+
return address;
260+
}
243261

244-
function loadedPublicKey(): string | undefined {
245-
return connectionState.publicKey;
262+
export async function setWallet(walletId: string) {
263+
localStorage.setItem(SELECTED_WALLET_ID, walletId);
264+
kit.setWallet(walletId);
246265
}
247266

248-
function setPublicKey(data: string): void {
249-
connectionState.publicKey = data;
267+
export async function disconnect(callback?: () => Promise<void>) {
268+
localStorage.removeItem(SELECTED_WALLET_ID);
269+
kit.disconnect();
270+
if (callback) await callback();
250271
}
251272

252-
export { kit, loadedPublicKey, setPublicKey };
273+
export async function connect(callback?: () => Promise<void>) {
274+
await kit.openModal({
275+
onWalletSelected: async (option) => {
276+
try {
277+
await setWallet(option.id);
278+
if (callback) await callback();
279+
} catch (e) {
280+
console.error(e);
281+
}
282+
return option.id;
283+
},
284+
});
285+
}
253286
```
254287

255-
In the code above, we created an instance of the kit and two simple functions that will take care of "setting" and "loading" the public key of the user. This lets us use the user's public key elsewhere in our code. The kit is started with Freighter as the default wallet, and the Testnet network as the default network. You can learn more about how the kit works in [the StellarWalletsKit documentation](https://stellarwalletskit.dev/)
288+
In the code above, we instantiate the kit with desired settings and export it. We also wrap some kit functions and add custom functionality, such as augmenting the kit by allowing it to remember which wallet options was selected between page refreshes (that's the `localStorage` bit). The kit requires a `selectedWalletId` even before the user selects one, so we also work around this limitation, as the code comment explains. You can learn more about how the kit works in [the StellarWalletsKit documentation](https://stellarwalletskit.dev/)
256289

257-
Now we're going to add a "Connect" button to the page which will open the kit's built in modal, and prompt the user to use their preferred wallet. Once the user picks their preferred wallet and grants permission to accept requests from the website, we will fetch the public key and the "Connect" button will be replaced with a message saying, "Signed in as [their public key]".
290+
Now we're going to add a "Connect" button to the page which will open the kit's built-in modal, and prompt the user to use their preferred wallet. Once the user picks their preferred wallet and grants permission to accept requests from the website, we will fetch the public key and the "Connect" button will be replaced with a message saying, "Signed in as [their public key]".
258291

259292
Now let's add a new component to the `src/components` directory called `ConnectWallet.astro` with the following content:
260293

261294
```html title="src/components/ConnectWallet.astro"
262295
<div id="connect-wrap" class="wrap" aria-live="polite">
263-
<div class="ellipsis">
264-
<button data-connect aria-controls="connect-wrap">Connect</button>
265-
</div>
296+
&nbsp;
297+
<div class="ellipsis"></div>
298+
<button style="display:none" data-connect aria-controls="connect-wrap">
299+
Connect
300+
</button>
301+
<button style="display:none" data-disconnect aria-controls="connect-wrap">
302+
Disconnect
303+
</button>
266304
</div>
267305

268306
<style>
269307
.wrap {
270308
text-align: center;
309+
display: flex;
310+
width: 18em;
311+
margin: auto;
312+
justify-content: center;
313+
line-height: 2.7rem;
314+
gap: 0.5rem;
271315
}
272316
273317
.ellipsis {
274-
line-height: 2.7rem;
275-
margin: auto;
276-
max-width: 12rem;
277318
overflow: hidden;
278319
text-overflow: ellipsis;
279320
text-align: center;
@@ -282,38 +323,48 @@ Now let's add a new component to the `src/components` directory called `ConnectW
282323
</style>
283324

284325
<script>
285-
import { kit, setPublicKey } from "../stellar-wallets-kit";
286-
287-
const ellipsis = document.querySelector("#connect-wrap .ellipsis");
288-
const button = document.querySelector("[data-connect]");
289-
290-
async function setLoggedIn(publicKey: string) {
291-
ellipsis.innerHTML = `Signed in as ${publicKey}`;
292-
ellipsis.title = publicKey;
326+
import { getPublicKey, connect, disconnect } from "../stellar-wallets-kit";
327+
328+
const ellipsis = document.querySelector(
329+
"#connect-wrap .ellipsis",
330+
) as HTMLElement;
331+
const connectButton = document.querySelector("[data-connect]") as HTMLButtonElement;
332+
const disconnectButton = document.querySelector(
333+
"[data-disconnect]",
334+
) as HTMLButtonElement;
335+
336+
async function showDisconnected() {
337+
ellipsis.innerHTML = "";
338+
ellipsis.removeAttribute("title");
339+
connectButton.style.removeProperty("display");
340+
disconnectButton.style.display = "none";
293341
}
294342
295-
button.addEventListener("click", async () => {
296-
button.disabled = true;
297-
298-
try {
299-
await kit.openModal({
300-
onWalletSelected: async (option) => {
301-
try {
302-
kit.setWallet(option.id);
303-
const { address } = await kit.getAddress();
304-
setPublicKey(address);
305-
await setLoggedIn(address);
306-
} catch (e) {
307-
console.error(e);
308-
}
309-
},
310-
});
311-
} catch (e) {
312-
console.error(e);
343+
async function showConnected() {
344+
const publicKey = await getPublicKey();
345+
if (publicKey) {
346+
ellipsis.innerHTML = `Signed in as ${publicKey}`;
347+
ellipsis.title = publicKey ?? "";
348+
connectButton.style.display = "none";
349+
disconnectButton.style.removeProperty("display");
350+
} else {
351+
showDisconnected();
313352
}
353+
}
314354
315-
button.disabled = false;
355+
connectButton.addEventListener("click", async () => {
356+
await connect(showConnected);
316357
});
358+
359+
disconnectButton.addEventListener("click", async () => {
360+
disconnect(showDisconnected);
361+
});
362+
363+
if (await getPublicKey()) {
364+
showConnected();
365+
} else {
366+
showDisconnected();
367+
}
317368
</script>
318369
```
319370
@@ -325,7 +376,7 @@ And all the `script` declarations get bundled together and included intelligentl
325376
326377
You can read more about this in [Astro's page about client-side scripts](https://docs.astro.build/en/guides/client-side-scripts/).
327378
328-
The code itself here is pretty self-explanatory. We import the wallets kit from the file we created before. Then, when the user clicks on the button, we launch the built-in modal do display to the user connection options. Once the user picks their preferred wallet, we set it as the wallets kit's default wallet before requesting and saving the address.
379+
The code itself here is pretty self-explanatory. We import `kit` from the file we created before. Then, when the user clicks on the sign-in button, we call the `connect` function we created in our `stellar-wallets-kit.ts` file above. This will launch the built-in StellarWalletsKit modal, which allows the user to pick from the wallet options we configured (we configured all of them, with `allowAllModules`). We pass our own `setLoggedIn` function as the callback, which will be called in the `onWalletSelected` function in `stellar-wallets-kit.ts`. We end by updating the UI, based on whether the user is currently connected or not.
329380
330381
Now we can import the component in the frontmatter of `pages/index.astro`:
331382
@@ -341,7 +392,7 @@ Now we can import the component in the frontmatter of `pages/index.astro`:
341392
And add it right below the `<h1>`:
342393
343394
```diff title="pages/index.astro"
344-
<h1>{greeting}</h1>
395+
<h1>{greeting}</h1>
345396
+<ConnectWallet />
346397
```
347398
@@ -366,19 +417,22 @@ Current value: <strong id="current-value" aria-live="polite">???</strong><br />
366417
<button data-increment aria-controls="current-value">Increment</button>
367418
368419
<script>
369-
import { kit, loadedPublicKey } from "../stellar-wallets-kit";
420+
import { getPublicKey, kit } from "../stellar-wallets-kit";
370421
import incrementor from "../contracts/soroban_increment_contract";
371-
const button = document.querySelector("[data-increment]");
372-
const currentValue = document.querySelector("#current-value");
422+
const button = document.querySelector(
423+
"[data-increment]",
424+
) as HTMLButtonElement;
425+
const currentValue = document.querySelector("#current-value") as HTMLElement;
373426
374427
button.addEventListener("click", async () => {
375-
const publicKey = loadedPublicKey();
428+
const publicKey = await getPublicKey();
376429
377430
if (!publicKey) {
378431
alert("Please connect your wallet first");
379432
return;
380433
} else {
381434
incrementor.options.publicKey = publicKey;
435+
incrementor.options.signTransaction = signTransaction;
382436
}
383437
384438
button.disabled = true;
@@ -387,31 +441,26 @@ Current value: <strong id="current-value" aria-live="polite">???</strong><br />
387441
currentValue.innerHTML +
388442
'<span class="visually-hidden"> – updating…</span>';
389443
390-
const tx = await incrementor.increment();
391-
392444
try {
393-
const { result } = await tx.signAndSend({
394-
signTransaction: async (xdr) => {
395-
return await kit.signTransaction(xdr);
396-
},
397-
});
445+
const tx = await incrementor.increment();
446+
const { result } = await tx.signAndSend();
398447
399448
// Only use `innerHTML` with contract values you trust!
400449
// Blindly using values from an untrusted contract opens your users to script injection attacks!
401450
currentValue.innerHTML = result.toString();
402451
} catch (e) {
403452
console.error(e);
453+
} finally {
454+
button.disabled = false;
455+
button.classList.remove("loading");
404456
}
405-
406-
button.disabled = false;
407-
button.classList.remove("loading");
408457
});
409458
</script>
410459
```
411460
412461
This should be somewhat familiar by now. We have a `script` that, thanks to Astro's build system, can `import` modules directly. We use `document.querySelector` to find the elements defined above. And we add a `click` handler to the button, which calls `increment` and updates the value on the page. It also sets the button to `disabled` and adds a `loading` class while the call is in progress to prevent the user from clicking it again and visually communicate that something is happening. For people using screen readers, the loading state is communicated with the [visually-hidden](https://www.a11yproject.com/posts/how-to-hide-content/) span, which will be announced to them thanks to the `aria` tags we saw before.
413462
414-
The biggest difference from the call to `greeter.hello` is that this transaction gets executed in two steps. The initial call to `increment` constructs a Soroban transaction and then makes an RPC call to _simulate_ it. For read-only calls like `hello`, this is all you need, so you can get the `result` right away. For write calls like `increment`, you then need to `signAndSend` before the transaction actually gets included in the ledger.
463+
The biggest difference from the call to `greeter.hello` is that this transaction gets executed in two steps. The initial call to `increment` constructs a Soroban transaction and then makes an RPC call to _simulate_ it. For read-only calls like `hello`, this is all you need, so you can get the `result` right away. For write calls like `increment`, you then need to `signAndSend` before the transaction actually gets included in the ledger. You also need to make sure you set a valid `publicKey` and a `signTransaction` method.
415464
416465
:::info
417466

0 commit comments

Comments
 (0)