LLM Notice: This documentation site supports content negotiation for AI agents. Request any page with Accept: text/markdown or Accept: text/plain header to receive Markdown instead of HTML. Alternatively, append ?format=md to any URL. All markdown files are available at /md/ prefix paths. For all content in one file, visit /llms-full.txt
Skip to main content

Build a Walletless Mobile App (PWA)

In this tutorial, we delve into the intricacies of crafting an accessible Progressive Web App (PWA) on the Flow blockchain, tackling the challenge of mobile mainstream accessibility in web3. We recognize the complexity of current onboarding processes, so we'll guide you through a streamlined approach, featuring a seamless walletless mobile login to alleviate the often daunting task for new users.

Understanding PWAs

PWAs have garnered attention recently, with platforms like friend.tech leading the way in popularity. PWAs blur the lines between web pages and mobile applications, offering an immersive, app-like experience directly from your browser. You can easily add a shortcut to your home screen, and the PWA operates just like a native application would. Beyond these capabilities, PWAs also boast offline functionality and support for push notifications, among many other features.

Explore walletless onboarding

Walletless onboarding is a groundbreaking feature that allows users to securely interact with decentralized applications (dApps) in a matter of seconds, all without the traditional need to create a blockchain wallet. This method effectively simplifies the user experience, abstracting the complexities of blockchain technology to facilitate swift and straightforward app access. For a deeper dive into walletless onboarding and its integration with Flow, feel free to explore the following resource: Flow Magic Integration.

Detailed steps

To effectively follow this tutorial, the developer requires a few essential libraries and integrations. Additionally, there is a ready-made flow scaffold called FCL PWA that contains the completed tutorial code, providing a solid foundation for you to build your PWA!

Dependencies

  1. Magic Account: To start, set up an app on magic.link, where you'll obtain an API key crucial for further steps.
  2. Magic SDK: Essential to integrate Magic's functionality in your project, and you can find it here.
  3. Magic Flow SDK: This SDK allows Magic's integration with Flow. You can install it from this link.
  4. Flow Client Library (FCL): As the JavaScript SDK for the Flow blockchain, FCL allows developers to create applications that seamlessly interact with the Flow blockchain and its smart contracts.
  5. React: We'll build our project with the React framework.

Set up up PWA and testing locally

Initiate the creation of a new React app, opting for the PWA template with the following command:


_10
npx create-react-app name-of-our-PWA-app --template cra-template-pwa

Ensure that serviceWorkerRegistration.register() in index.js is appropriately configured to support offline capabilities of your PWA.

Proceed to build your application with your preferred build tool. In this example, we will use Yarn:


_10
yarn run build

Following the build, you can serve your application locally with:


_10
npx serve -s build

To thoroughly test your PWA, especially on a mobile device, we strongly recommend that you use a tool like ngrok. Start ngrok and point it to the local port on which your application runs:


_10
ngrok http 3000

Grab the generated link, and you can now access and test your PWA directly on your mobile device!

You can now grab the link and go to it on your mobile device to test the PWA!

Integrate with Magic

Proceed to install the Magic-related dependencies in your project. Ensure you add your Magic app's key as an environment variable for secure access:


_10
yarn add magic-sdk @magic-ext/flow @onflow/fcl

Let's create a helper file, magic.js, to manage our Magic extension setup. Ensure that your environment variable with the Magic API key is correctly set before you proceed.


_13
import { Magic } from "magic-sdk";
_13
import { FlowExtension } from "@magic-ext/flow";
_13
_13
const magic = new Magic(process.env.REACT_APP_MAGIC_KEY, {
_13
extensions: [
_13
new FlowExtension({
_13
rpcUrl: "https://rest-testnet.onflow.org",
_13
network: "testnet",
_13
}),
_13
],
_13
});
_13
_13
export default magic;

Anytime you need to interface with chain, you will use this magic instance.

React context and provider for user data

currentUserContext.js

This file creates a React context that you'll use to share the current user's data across your application.

React Context: It is created using React.createContext() which provides a way to pass data through the component tree without having to pass props down manually at every level.


_10
import React from "react";
_10
_10
const CurrentUserContext = React.createContext();
_10
_10
export default CurrentUserContext;

currentUserProvider.js

This file defines a React provider component that uses the context created above. This provider component will wrap around your application's components, which allows them to access the current user's data.

  • useState: To create state variables to store the current user's data and the loading status.
  • useEffect: To fetch the user's data from Magic when the component mounts.
  • magic.user.isLoggedIn: Checks if a user is logged in.
  • magic.user.getMetadata: Fetches the user's metadata.

_37
import React, { useState, useEffect } from "react";
_37
import CurrentUserContext from "./currentUserContext";
_37
import magic from "./magic"; // You should have this from the previous part of the tutorial
_37
_37
const CurrentUserProvider = ({ children }) => {
_37
const [currentUser, setCurrentUser] = useState(null);
_37
const [userStatusLoading, setUserStatusLoading] = useState(false);
_37
_37
useEffect(() => {
_37
const fetchUserData = async () => {
_37
try {
_37
setUserStatusLoading(true);
_37
const magicIsLoggedIn = await magic.user.isLoggedIn();
_37
if (magicIsLoggedIn) {
_37
const metaData = await magic.user.getMetadata();
_37
setCurrentUser(metaData);
_37
}
_37
} catch (error) {
_37
console.error("Error fetching user data:", error);
_37
} finally {
_37
setUserStatusLoading(false);
_37
}
_37
};
_37
_37
fetchUserData();
_37
}, []);
_37
_37
return (
_37
<CurrentUserContext.Provider
_37
value={{ currentUser, setCurrentUser, userStatusLoading }}
_37
>
_37
{children}
_37
</CurrentUserContext.Provider>
_37
);
_37
};
_37
_37
export default CurrentUserProvider;

Log in the user

This part shows how to log in a user with Magic's SMS authentication.

  • magic.auth.loginWithSMS: A function that Magic provides to authenticate users with their phone number.
  • setCurrentUser: Updates the user's data in the context.

_12
import magic from "./magic";
_12
_12
const login = async (phoneNumber) => {
_12
if(!phoneNumber) {
_12
return;
_12
}
_12
_12
await magic.auth.loginWithSMS({ phoneNumber });
_12
_12
const metaData = await magic.user.getMetadata();
_12
setCurrentUser(metaData);
_12
};

Scripts/Transactions with Flow

This example shows how to interact with the Flow blockchain using FCL and Magic for authorization.

  • fcl.send: A function that FCL provides to send transactions or scripts to the Flow blockchain.
  • AUTHORIZATION_FUNCTION: The authorization function that Magic provides to sign transactions.

_26
import * as fcl from "@onflow/fcl";
_26
import magic from "./magic";
_26
_26
fcl.config({
_26
"flow.network": "testnet",
_26
"accessNode.api": "https://rest-testnet.onflow.org",
_26
"discovery.wallet": `https://fcl-discovery.onflow.org/testnet/authn`,
_26
})
_26
_26
const AUTHORIZATION_FUNCTION = magic.flow.authorization;
_26
_26
const transactionExample = async (currentUser) => {
_26
const response = await fcl.send([
_26
fcl.transaction`
_26
// Your Cadence code here
_26
`,
_26
fcl.args([
_26
fcl.arg(currentUser.publicAddress, fcl.types.Address),
_26
]),
_26
fcl.proposer(AUTHORIZATION_FUNCTION),
_26
fcl.authorizations([AUTHORIZATION_FUNCTION]),
_26
fcl.payer(AUTHORIZATION_FUNCTION),
_26
fcl.limit(9999),
_26
]);
_26
const transactionData = await fcl.tx(response).onceExecuted();
_26
};

Account linking with Flow

Now we can unlock the real power of Flow. Lets say you have another Flow account and you want to link the "magic" account as a child account so that you can take full custody of whatever is in the magic account. You can do this via Hybird Custody.

You can view the hybrid custody repo and contracts here: https://github.com/onflow/hybrid-custody

We will maintain two accounts within the app. The child(magic) account from earlier and new non custodial FCL flow account. We won't go over how to log in with FCL here and use it, but you can do the normal process to obtain the parent account.

After you log in to the parent account and child(magic) account, you can link the account with the following transaction.


_72
#allowAccountLinking
_72
_72
import HybridCustody from 0x294e44e1ec6993c6
_72
_72
import CapabilityFactory from 0x294e44e1ec6993c6
_72
import CapabilityDelegator from 0x294e44e1ec6993c6
_72
import CapabilityFilter from 0x294e44e1ec6993c6
_72
_72
import MetadataViews from 0x631e88ae7f1d7c20
_72
_72
transaction(parentFilterAddress: Address?, childAccountFactoryAddress: Address, childAccountFilterAddress: Address) {
_72
prepare(childAcct: AuthAccount, parentAcct: AuthAccount) {
_72
// --------------------- Begin setup of child account ---------------------
_72
var acctCap = childAcct.getCapability<&AuthAccount>(HybridCustody.LinkedAccountPrivatePath)
_72
if !acctCap.check() {
_72
acctCap = childAcct.linkAccount(HybridCustody.LinkedAccountPrivatePath)!
_72
}
_72
_72
if childAcct.borrow<&HybridCustody.OwnedAccount>(from: HybridCustody.OwnedAccountStoragePath) == nil {
_72
let ownedAccount <- HybridCustody.createOwnedAccount(acct: acctCap)
_72
childAcct.save(<-ownedAccount, to: HybridCustody.OwnedAccountStoragePath)
_72
}
_72
_72
// check that paths are all configured properly
_72
childAcct.unlink(HybridCustody.OwnedAccountPrivatePath)
_72
childAcct.link<&HybridCustody.OwnedAccount{HybridCustody.BorrowableAccount, HybridCustody.OwnedAccountPublic, MetadataViews.Resolver}>(HybridCustody.OwnedAccountPrivatePath, target: HybridCustody.OwnedAccountStoragePath)
_72
_72
childAcct.unlink(HybridCustody.OwnedAccountPublicPath)
_72
childAcct.link<&HybridCustody.OwnedAccount{HybridCustody.OwnedAccountPublic, MetadataViews.Resolver}>(HybridCustody.OwnedAccountPublicPath, target: HybridCustody.OwnedAccountStoragePath)
_72
// --------------------- End setup of child account ---------------------
_72
_72
// --------------------- Begin setup of parent account ---------------------
_72
var filter: Capability<&{CapabilityFilter.Filter}>? = nil
_72
if parentFilterAddress != nil {
_72
filter = getAccount(parentFilterAddress!).getCapability<&{CapabilityFilter.Filter}>(CapabilityFilter.PublicPath)
_72
}
_72
_72
if parentAcct.borrow<&HybridCustody.Manager>(from: HybridCustody.ManagerStoragePath) == nil {
_72
let m <- HybridCustody.createManager(filter: filter)
_72
parentAcct.save(<- m, to: HybridCustody.ManagerStoragePath)
_72
}
_72
_72
parentAcct.unlink(HybridCustody.ManagerPublicPath)
_72
parentAcct.unlink(HybridCustody.ManagerPrivatePath)
_72
_72
parentAcct.link<&HybridCustody.Manager{HybridCustody.ManagerPrivate, HybridCustody.ManagerPublic}>(HybridCustody.OwnedAccountPrivatePath, target: HybridCustody.ManagerStoragePath)
_72
parentAcct.link<&HybridCustody.Manager{HybridCustody.ManagerPublic}>(HybridCustody.ManagerPublicPath, target: HybridCustody.ManagerStoragePath)
_72
// --------------------- End setup of parent account ---------------------
_72
_72
// Publish account to parent
_72
let owned = childAcct.borrow<&HybridCustody.OwnedAccount>(from: HybridCustody.OwnedAccountStoragePath)
_72
?? panic("owned account not found")
_72
_72
let factory = getAccount(childAccountFactoryAddress).getCapability<&CapabilityFactory.Manager{CapabilityFactory.Getter}>(CapabilityFactory.PublicPath)
_72
assert(factory.check(), message: "factory address is not configured properly")
_72
_72
let filterForChild = getAccount(childAccountFilterAddress).getCapability<&{CapabilityFilter.Filter}>(CapabilityFilter.PublicPath)
_72
assert(filterForChild.check(), message: "capability filter is not configured properly")
_72
_72
owned.publishToParent(parentAddress: parentAcct.address, factory: factory, filter: filterForChild)
_72
_72
// claim the account on the parent
_72
let inboxName = HybridCustody.getChildAccountIdentifier(parentAcct.address)
_72
let cap = parentAcct.inbox.claim<&HybridCustody.ChildAccount{HybridCustody.AccountPrivate, HybridCustody.AccountPublic, MetadataViews.Resolver}>(inboxName, provider: childAcct.address)
_72
?? panic("child account cap not found")
_72
_72
let manager = parentAcct.borrow<&HybridCustody.Manager>(from: HybridCustody.ManagerStoragePath)
_72
?? panic("manager no found")
_72
_72
manager.addAccount(cap: cap)
_72
}
_72
}

info

For the sake of this example, well use some pre defined factory and filter implementations. You can find them on the repo, but on testnet we can use 0x1055970ee34ef4dc and 0xe2664be06bb0fe62 for the factory and filter address respectively. 0x1055970ee34ef4dc provides NFT capabilities and 0xe2664be06bb0fe62 which is the AllowAllFilter. These generalized implementations likely cover most use cases, but you'll want to weigh the decision to use them according to your risk tolerance and specific scenario

Now, for viewing all parent accounts linked to a child account and removing a linked account, you can follow similar patterns, using Cadence scripts and transactions as required.


_12
import HybridCustody from 0x294e44e1ec6993c6
_12
_12
access(all) fun main(child: Address): [Address] {
_12
let acct = getAuthAccount(child)
_12
let o = acct.borrow<&HybridCustody.OwnedAccount>(from: HybridCustody.OwnedAccountStoragePath)
_12
_12
if o == nil {
_12
return []
_12
}
_12
_12
return o!.getParentStatuses().keys
_12
}

Finally, to remove a linked account, you can run the following cadence transaction:


_24
await fcl.send([
_24
fcl.transaction`
_24
import HybridCustody from 0x294e44e1ec6993c6
_24
_24
transaction(parent: Address) {
_24
prepare(acct: AuthAccount) {
_24
let owned = acct.borrow<&HybridCustody.OwnedAccount>(from: HybridCustody.OwnedAccountStoragePath)
_24
?? panic("owned not found")
_24
_24
owned.removeParent(parent: parent)
_24
_24
let manager = getAccount(parent).getCapability<&HybridCustody.Manager{HybridCustody.ManagerPublic}>(HybridCustody.ManagerPublicPath)
_24
.borrow() ?? panic("manager not found")
_24
let children = manager.getChildAddresses()
_24
assert(!children.contains(acct.address), message: "removed child is still in manager resource")
_24
}
_24
}
_24
`,
_24
fcl.args([fcl.arg(account, t.Address)]),
_24
fcl.proposer(AUTHORIZATION_FUNCTION),
_24
fcl.authorizations([AUTHORIZATION_FUNCTION]),
_24
fcl.payer(AUTHORIZATION_FUNCTION),
_24
fcl.limit(9999),
_24
]);

Video guide

Video Title

Sample Flow PWA: Balloon inflation game

Game Overview

This PWA game revolves around inflating a virtual balloon, with a twist! The players engage with the balloon, witnessing its growth and color transformation, all while being cautious not to pop it. The ultimate goal is to mint the balloon's state as an NFT to commemorate their achievement.

You can view the game here. Visit this on your mobile device(for iOS use Safari).

The full code for this game can be found here: https://github.com/onflow/inflation

pwa_prompt

pwa_mint_balloon_thumbnail

pwa_link_account_thumbnail

Key game features:

  1. Balloon inflation:
    • As the player inflates the balloon, it expands and changes color.
    • A hidden inflation threshold is set. If a player exceeds this limit, the ballon bursts.
  2. NFT minting:
    • Satisfied with their balloon's size, players have the option to mint it into an NFT, which creates a permanent token of their accomplishment.
  3. Balloon collection:
    • Post-minting, players can view and showcase their collection of balloon NFTs.
  4. Account linking and custody:
    • Players initially interact with the game in a walletless fashion via Magic.
    • When ready to claim full ownership of their balloon NFTs, they can link their Magic account to a non-custodial FCL wallet of their choice.

Integration with Flow and Magic

The entire game is crafted upon the previously discussed setup, ensuring a seamless and user-friendly experience.

Playing the game:

  • Walletless interaction: Users can jump right into the game, inflate the balloon and enjoy the gameplay without any blockchain wallet setup.
  • Inflation and visuals: The balloon's size and color change in real-time, which provides instant visual feedback to the player.

Minting and viewing NFTs:

  • Magic login for minting: To mint their balloon as an NFT, players log in with Magic and embrace a walletless experience.
  • Viewing NFT Collection: Post-minting, players can easily access and view their collection of balloon NFTs.

Take custody with Account Linking:

  • Secure custody: Players who want to secure their balloon NFTs can use Account Linking to connect their Magic account to their personal non-custodial FCL wallet.
  • Full ownership: This step ensures that players have complete control and custody over their digital assets.

Conclusion

The balloon inflation game stands as a testament to the seamless integration of Flow, Magic, and PWA technology, creating a user-friendly blockchain game that is accessible, engaging, and secure. Players can enjoy the game, mint NFTs, and take full ownership of their digital assets with ease and convenience.