Skip to content

Commit

Permalink
fix: update frontend tutorial to latest libs
Browse files Browse the repository at this point in the history
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
  • Loading branch information
chadoh committed Dec 11, 2024
1 parent a394359 commit d4e1b9b
Showing 1 changed file with 136 additions and 88 deletions.
224 changes: 136 additions & 88 deletions docs/build/apps/dapp-frontend.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,32 @@ Let's get started.

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.

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.
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.

From our `soroban-hello-world` directory, run the following command to add the Astro template files.
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:

```sh
stellar contract init ./ \
--frontend-template https://github.com/stellar/soroban-astro-template
```
npx degit stellar/soroban-template-astro first-soroban-app
cd first-soroban-app
git init
git add .
git commit -m "first commit: initialize from stellar/soroban-template-astro"
```

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

```bash
├── contracts
│   ├── hello_world
│   └── increment
├── CONTRIBUTING.md
├── Cargo.toml
├── Cargo.lock
├── initialize.js
├── package-lock.json
├── package.json
├── packages
├── public
│   └── favicon.svg
├── src
│   ├── components
│   │   └── Card.astro
Expand All @@ -49,6 +56,8 @@ This will add the following to your project, which we'll go over in more detail
└── tsconfig.json
```

The `contracts` are the same ones you walked through in the previous steps of the tutorial.

## Generate an NPM package for the Hello World contract

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).
Expand Down Expand Up @@ -91,29 +100,26 @@ Let's take a look at the contents of the `.env` file:

```
# Prefix with "PUBLIC_" to make available in Astro frontend files
PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
PUBLIC_SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"
PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"
SOROBAN_ACCOUNT="me"
SOROBAN_NETWORK="standalone"
# env vars that begin with PUBLIC_ will be available to the client
PUBLIC_SOROBAN_RPC_URL=$SOROBAN_RPC_URL
STELLAR_ACCOUNT="me"
STELLAR_NETWORK="standalone"
```

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:

```diff
# Prefix with "PUBLIC_" to make available in Astro frontend files
-PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
+PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
-PUBLIC_SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"
+PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org:443"

-SOROBAN_ACCOUNT="me"
+SOROBAN_ACCOUNT="alice"
-SOROBAN_NETWORK="standalone"
+SOROBAN_NETWORK="testnet"
-PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
+PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
-PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"
+PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443"

-STELLAR_ACCOUNT="me"
+STELLAR_ACCOUNT="alice"
-STELLAR_NETWORK="standalone"
+STELLAR_NETWORK="testnet"
```

:::info
Expand Down Expand Up @@ -228,52 +234,87 @@ import {
allowAllModules,
FREIGHTER_ID,
StellarWalletsKit,
WalletNetwork,
} from "@creit.tech/stellar-wallets-kit";

const kit: StellarWalletsKit = new StellarWalletsKit({
const SELECTED_WALLET_ID = "selectedWalletId";

function getSelectedWalletId() {
return localStorage.getItem(SELECTED_WALLET_ID);
}

const kit = new StellarWalletsKit({
modules: allowAllModules(),
network: WalletNetwork.TESTNET,
selectedWalletId: FREIGHTER_ID,
network: import.meta.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE,
// StellarWalletsKit forces you to specify a wallet, even if the user didn't
// select one yet, so we default to Freighter.
// We'll work around this later in `getPublicKey`.
selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID,
});

const connectionState: { publicKey: string | undefined } = {
publicKey: undefined,
};
export const signTransaction = kit.signTransaction.bind(kit);

export async function getPublicKey() {
if (!getSelectedWalletId()) return null;
const { address } = await kit.getAddress();
return address;
}

function loadedPublicKey(): string | undefined {
return connectionState.publicKey;
export async function setWallet(walletId: string) {
localStorage.setItem(SELECTED_WALLET_ID, walletId);
kit.setWallet(walletId);
}

function setPublicKey(data: string): void {
connectionState.publicKey = data;
export async function disconnect(callback?: () => Promise<void>) {
localStorage.removeItem(SELECTED_WALLET_ID);
kit.disconnect();
if (callback) await callback();
}

export { kit, loadedPublicKey, setPublicKey };
export async function connect(callback?: () => Promise<void>) {
await kit.openModal({
onWalletSelected: async (option) => {
try {
await setWallet(option.id);
if (callback) await callback();
} catch (e) {
console.error(e);
}
return option.id;
},
});
}
```

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/)
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/)

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]".
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]".

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

```html title="src/components/ConnectWallet.astro"
<div id="connect-wrap" class="wrap" aria-live="polite">
<div class="ellipsis">
<button data-connect aria-controls="connect-wrap">Connect</button>
</div>
&nbsp;
<div class="ellipsis"></div>
<button style="display:none" data-connect aria-controls="connect-wrap">
Connect
</button>
<button style="display:none" data-disconnect aria-controls="connect-wrap">
Disconnect
</button>
</div>

<style>
.wrap {
text-align: center;
display: flex;
width: 18em;
margin: auto;
justify-content: center;
line-height: 2.7rem;
gap: 0.5rem;
}
.ellipsis {
line-height: 2.7rem;
margin: auto;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
Expand All @@ -282,38 +323,48 @@ Now let's add a new component to the `src/components` directory called `ConnectW
</style>

<script>
import { kit, setPublicKey } from "../stellar-wallets-kit";
const ellipsis = document.querySelector("#connect-wrap .ellipsis");
const button = document.querySelector("[data-connect]");
async function setLoggedIn(publicKey: string) {
ellipsis.innerHTML = `Signed in as ${publicKey}`;
ellipsis.title = publicKey;
import { getPublicKey, connect, disconnect } from "../stellar-wallets-kit";
const ellipsis = document.querySelector(
"#connect-wrap .ellipsis",
) as HTMLElement;
const connectButton = document.querySelector("[data-connect]") as HTMLButtonElement;
const disconnectButton = document.querySelector(
"[data-disconnect]",
) as HTMLButtonElement;
async function showDisconnected() {
ellipsis.innerHTML = "";
ellipsis.removeAttribute("title");
connectButton.style.removeProperty("display");
disconnectButton.style.display = "none";
}
button.addEventListener("click", async () => {
button.disabled = true;
try {
await kit.openModal({
onWalletSelected: async (option) => {
try {
kit.setWallet(option.id);
const { address } = await kit.getAddress();
setPublicKey(address);
await setLoggedIn(address);
} catch (e) {
console.error(e);
}
},
});
} catch (e) {
console.error(e);
async function showConnected() {
const publicKey = await getPublicKey();
if (publicKey) {
ellipsis.innerHTML = `Signed in as ${publicKey}`;
ellipsis.title = publicKey ?? "";
connectButton.style.display = "none";
disconnectButton.style.removeProperty("display");
} else {
showDisconnected();
}
}
button.disabled = false;
connectButton.addEventListener("click", async () => {
await connect(showConnected);
});
disconnectButton.addEventListener("click", async () => {
disconnect(showDisconnected);
});
if (await getPublicKey()) {
showConnected();
} else {
showDisconnected();
}
</script>
```
Expand All @@ -325,7 +376,7 @@ And all the `script` declarations get bundled together and included intelligentl
You can read more about this in [Astro's page about client-side scripts](https://docs.astro.build/en/guides/client-side-scripts/).
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.
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.
Now we can import the component in the frontmatter of `pages/index.astro`:
Expand All @@ -341,7 +392,7 @@ Now we can import the component in the frontmatter of `pages/index.astro`:
And add it right below the `<h1>`:
```diff title="pages/index.astro"
<h1>{greeting}</h1>
<h1>{greeting}</h1>
+<ConnectWallet />
```
Expand All @@ -366,19 +417,22 @@ Current value: <strong id="current-value" aria-live="polite">???</strong><br />
<button data-increment aria-controls="current-value">Increment</button>
<script>
import { kit, loadedPublicKey } from "../stellar-wallets-kit";
import { getPublicKey, kit } from "../stellar-wallets-kit";
import incrementor from "../contracts/soroban_increment_contract";
const button = document.querySelector("[data-increment]");
const currentValue = document.querySelector("#current-value");
const button = document.querySelector(
"[data-increment]",
) as HTMLButtonElement;
const currentValue = document.querySelector("#current-value") as HTMLElement;
button.addEventListener("click", async () => {
const publicKey = loadedPublicKey();
const publicKey = await getPublicKey();
if (!publicKey) {
alert("Please connect your wallet first");
return;
} else {
incrementor.options.publicKey = publicKey;
incrementor.options.signTransaction = signTransaction;
}
button.disabled = true;
Expand All @@ -387,32 +441,26 @@ Current value: <strong id="current-value" aria-live="polite">???</strong><br />
currentValue.innerHTML +
'<span class="visually-hidden"> – updating…</span>';
const tx = await incrementor.increment();
try {
const { result } = await tx.signAndSend({
signTransaction: async (xdr) => {
const { signedTxXdr } = await kit.signTransaction(xdr);
return signedTxXdr;
},
});
const tx = await incrementor.increment();
const { result } = await tx.signAndSend();
// Only use `innerHTML` with contract values you trust!
// Blindly using values from an untrusted contract opens your users to script injection attacks!
currentValue.innerHTML = result.toString();
} catch (e) {
console.error(e);
} finally {
button.disabled = false;
button.classList.remove("loading");
}
button.disabled = false;
button.classList.remove("loading");
});
</script>
```
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.
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.
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.
:::info
Expand Down

0 comments on commit d4e1b9b

Please sign in to comment.