Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This Turborepo includes the following example applications:

| Name | Description | Path & Links |
| --- | --- | --- |
| `@shelby-protocol/web` | A Next.js application showcasing Shelby integrations | [`apps/web`](./apps/web) |
| `@shelby-protocol/cross-chain-accounts` | Shelby cross chain accounts example | [`apps/cross-chain-accounts`](./apps/cross-chain-accounts) |

<!-- APPS_TABLE_END -->

Expand Down
File renamed without changes.
171 changes: 171 additions & 0 deletions apps/cross-chain-accounts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Shelby Cross-Chain Accounts Example

This example demonstrates how one can upload and view files using cross-chain (non-Aptos) wallets on the Shelby network.

### Prerequisites

- `node` and `pnpm`
- Shelby API key. For best performance, you are encouraged to generate an API key so your app will not hit the rate limit.
- Head to [Geomi](https://geomi.dev/) to generate an API key for `shelbydevnet` and add it to the `.env` file as `NEXT_PUBLIC_SHELBY_API_KEY=<my-api-key>`

### Starting the Demo dApp

Create an `.env` file by copying the `.env.example` file and fill out the required variables.

```bash
cp .env.example .env
```

Then install dependencies and run the dapp locally

```bash
pnpm install
pnpm run dev
```

### Implementation Details

The dApp provides a flow to connect an Aptos native or cross-chain wallet, upload a static file to the Shelby network, and view the account's uploaded files through a user interface.

### Upload a File

At a high level, uploading a file to Shelby includes 3 steps:

1. Encode the File
2. Register the file on chain (transaction submission)
3. Upload the file to Shelby RPC

#### Encode File

Encoding a file means we split the file into chunks, where each chunk has a `commitment hash`, and these are combined to make the `blob merkle root hash`.

We then send all these hashes to the blockchain so they can be verified with the storage providers.

```ts
export const encodeFile = async (file: File): Promise<BlobCommitments> => {
const data = Buffer.isBuffer(file)
? file
: Buffer.from(await file.arrayBuffer());

const commitments = await generateCommitments(data);

return commitments;
};
```

#### Register the file on chain (transaction submission)

> Note: To upload a file to the Shelby network, the account should hold PROTO tokens (1 PROTO for 1 upload). Make sure to fund your account by going to https://docs.shelby.xyz/docs/faucet

After we have the commitment hashes of the file, we can register the file on chain by submitting a transaction.

```ts
// Generate the transaction payload
const payload = ShelbyBlobClient.createWriteBlobCommitmentsPayload({
account: account.address,
blobName: file.name,
blobMerkleRoot: commitment.blob_merkle_root,
chunksetChunkCommitments: commitment.chunkset_commitments.map(
(chunkset) => chunkset.chunk_commitments
),
expirationMicros: (1000 * 60 * 60 * 24 * 30 + Date.now()) * 1000, // 30 days from now in microseconds
size: commitment.raw_data_size,
});
```

Once we have the transaction payload, we can submit it to the chain. As mentioned before, in this example we support both Aptos native and cross-chain wallet transaction submissions.

##### Aptos native wallets

> Note: Make sure your wallet is configured to use the `shelbydevnet` network. Petra (and some other wallets) lets you create a custom network, use those values
>
> - Node URL: https://api.devnet.shelby.xyz/v1
> - Faucet URL: https://faucet.devnet.shelby.xyz (APT faucet)
> - Indexer URL: https://api.devnet.shelby.xyz/v1/graphql

```ts
// Send the transaction to the connected wallet to sign and submit
const transaction: InputTransactionData = {
data: payload,
};
const transactionSubmitted = await signAndSubmitTransaction(transaction);
// Wait for transaction to be submitted on the chain
await getShelbyClient().aptos.waitForTransaction({
transactionHash: transactionSubmitted.hash,
});
```

##### Cross-chain wallets

Following the [cross-chain wallet docs](https://aptos.dev/build/sdks/wallet-adapter/x-chain-accounts#submitting-a-transaction), it is recommended to sponsor the transaction as we can assume a cross-chain wallet does not have APT to pay the transaction fees.

```ts
// Create the sponsor account
const privateKey = new Ed25519PrivateKey(
PrivateKey.formatPrivateKey(
process.env.NEXT_PUBLIC_SPONSOR_PRIVATE_KEY as string,
PrivateKeyVariants.Ed25519
)
);
const sponsorAccount = Account.fromPrivateKey({ privateKey });

// Build the transaction
const rawTransaction = await getShelbyClient().aptos.transaction.build.simple({
sender: account.address,
data: payload,
withFeePayer: true,
});

// Send the transaction to the connected (cross-chain) wallet to sign
const walletSignedTransaction = await signTransaction({
transactionOrPayload: rawTransaction,
});

// Sponsor signs the transaction
const sponsorAuthenticator = getShelbyClient().aptos.transaction.signAsFeePayer(
{
signer: sponsorAccount,
transaction: rawTransaction,
}
);

// Submit the transaction to chain
const transactionSubmitted =
await getShelbyClient().aptos.transaction.submit.simple({
transaction: rawTransaction,
senderAuthenticator: walletSignedTransaction.authenticator,
feePayerAuthenticator: sponsorAuthenticator,
});

// Wait for transaction to be submitted on the chain
await getShelbyClient().aptos.waitForTransaction({
transactionHash: transactionSubmitted.hash,
});
```

#### Upload the file to Shelby RPC

After we submit the transaction and register the commitment hashes on the chain, we can upload the file to the Shelby RPC so it can be verified with the storage providers to ensure that the uploaded data matches the one on the blockchain by comparing the hashes.

> Note: The RPC will make checks on-chain to ensure the file is there first, which is why registration can't happen in parallel with an upload to the RPC.

```ts
await getShelbyClient().rpc.putBlob({
account: account.address,
blobName: file.name,
blobData: new Uint8Array(await file.arrayBuffer()),
});
```

### View the account's uploaded files

To view the files uploaded by an account, we can simply query for the blobs.

```ts
const getBlobs = async (): Promise<BlobMetadata[]> => {
const blobs = await getShelbyClient().coordination.getAccountBlobs({
account: account.address,
});
return blobs;
};
```
File renamed without changes.
1 change: 1 addition & 0 deletions apps/cross-chain-accounts/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "@shelby-protocol/ui/globals.css";
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Toaster } from "@shelby-protocol/ui/components";
import localFont from "next/font/local";
import { WalletProvider } from "@/components/WalletProvider";
import "./globals.css";
import type { Metadata } from "next";

const geistSans = localFont({
src: "./fonts/GeistVF.woff",
Expand All @@ -12,8 +14,8 @@ const geistMono = localFont({
});

export const metadata: Metadata = {
Comment thread
0xmaayan marked this conversation as resolved.
title: "Create Next App",
description: "Generated by create next app",
title: "Shelby Cross Chain Accounts Example",
description: "A cross-chain accounts example",
};

export default function RootLayout({
Expand All @@ -24,7 +26,10 @@ export default function RootLayout({
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<WalletProvider>
{children}
<Toaster />
</WalletProvider>
</body>
</html>
);
Expand Down
28 changes: 28 additions & 0 deletions apps/cross-chain-accounts/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useState } from "react";
import { AccountBlobs } from "@/components/AccountBlobs";
import { FileUpload } from "@/components/FileUpload";
import { Header } from "@/components/Header";

export default function Home() {
const [refreshTrigger, setRefreshTrigger] = useState(0);

const handleUploadSuccess = () => {
setRefreshTrigger((prev) => prev + 1);
};

return (
<div className="min-h-screen p-5">
<Header />

{/* Main Content */}
<main className="flex-1">
<div className="max-w-4xl mx-auto space-y-6">
<FileUpload onUploadSuccess={handleUploadSuccess} />
<AccountBlobs refreshTrigger={refreshTrigger} />
</div>
</main>
</div>
);
}
20 changes: 20 additions & 0 deletions apps/cross-chain-accounts/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@shelby-protocol/ui/lib/utils",
"ui": "@shelby-protocol/ui/components"
}
}
114 changes: 114 additions & 0 deletions apps/cross-chain-accounts/components/AccountBlobs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useWallet } from "@aptos-labs/wallet-adapter-react";
import type { BlobMetadata } from "@shelby-protocol/sdk/browser";
import { useEffect, useState } from "react";
import { getShelbyClient, SHELBY_API_URL } from "@/utils/client";

interface AccountBlobsProps {
refreshTrigger?: number;
}

export const AccountBlobs = ({ refreshTrigger }: AccountBlobsProps) => {
const { account } = useWallet();
const [blobs, setBlobs] = useState<BlobMetadata[]>([]);

useEffect(() => {
if (!account) {
setBlobs([]);
return;
}
const getBlobs = async (): Promise<BlobMetadata[]> => {
const blobs = await getShelbyClient().coordination.getAccountBlobs({
account: account.address,
});
return blobs;
};

getBlobs().then((blobs) => {
setBlobs(blobs);
refreshTrigger;
});
}, [account, refreshTrigger]);

return (
<div className="border border-gray-200 dark:border-gray-700 rounded-xl p-6 bg-background">
{!account && (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">
Please connect your wallet to view blobs
</p>
</div>
)}
{account && blobs.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">
No blobs found for this account. Upload a file to get started!
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{blobs.map((blob) => (
<div
key={blob.name}
className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Image Section */}
<div className="w-full h-48 bg-gray-100 dark:bg-gray-700 p-2">
<div className="h-full relative">
<img
src={`${SHELBY_API_URL}/v1/blobs/${blob.owner.toString()}/${
blob.name
}`}
alt={blob.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
borderRadius: "4px",
}}
onError={(e) => {
console.error("Image failed to load:", e);
}}
/>
</div>
</div>

{/* Content Section */}
<div className="p-4 space-y-3">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white truncate">
{blob.name}
</h3>

<div className="space-y-2 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">
Owner:
</span>
<p className="font-mono text-xs bg-gray-50 dark:bg-gray-700 p-1 rounded mt-1 break-all">
{blob.owner.toString()}
</p>
</div>

<div>
<span className="text-gray-500 dark:text-gray-400">
Download:
</span>
<a
href={`${SHELBY_API_URL}/v1/blobs/${blob.owner.toString()}/${
blob.name
}`}
className="block text-blue-600 dark:text-blue-400 hover:underline text-xs mt-1 break-all"
target="_blank"
rel="noopener noreferrer"
>
View Image
</a>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
};
Loading