diff --git a/.env b/.env index ba2be7d..338edb0 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -VITE_PROFILE_ADDRESS=0xba1132bc08f82fe2 -VITE_ACCESS_NODE_API=https://rest-testnet.onflow.org -VITE_DISCOVERY_WALLET=https://fcl-discovery.onflow.org/testnet/authn \ No newline at end of file +VITE_FLOW_NETWORK=emulator +VITE_ACCESS_NODE_API=http://localhost:8888 +VITE_DISCOVERY_WALLET=http://localhost:8701/fcl/authn diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..338edb0 --- /dev/null +++ b/.env.local @@ -0,0 +1,3 @@ +VITE_FLOW_NETWORK=emulator +VITE_ACCESS_NODE_API=http://localhost:8888 +VITE_DISCOVERY_WALLET=http://localhost:8701/fcl/authn diff --git a/.env.testnet b/.env.testnet new file mode 100644 index 0000000..1b99837 --- /dev/null +++ b/.env.testnet @@ -0,0 +1,3 @@ +VITE_FLOW_NETWORK=testnet +VITE_ACCESS_NODE_API=https://rest-testnet.onflow.org +VITE_DISCOVERY_WALLET=https://fcl-discovery.onflow.org/testnet/authn diff --git a/README.md b/README.md index aaec72c..fabd90d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,24 @@ npm run dev npm run dev -- --open ``` +## Developing with Flow emulator + +**Pre-Requisite**: To develop locally, make sure you have the Flow CLI installed: https://docs.onflow.org/flow-cli/install/ + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start the emulator, deploy the contracts, followed by the development server: + +```bash +flow emulator # run emulator +flow deploy # deploy smart contracts +flow dev-wallet # run dev wallet + +npm run dev +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +> NOTE: If you are switching between testnet and the emulator without changing tabs, FCL will keep you logged in with your testnet address (or vice-versa). Remember to logout inbetween environments to avoid runtime errors! + ## Building Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then: diff --git a/cadence/contracts/Profile.cdc b/cadence/contracts/Profile.cdc new file mode 100644 index 0000000..3d6d265 --- /dev/null +++ b/cadence/contracts/Profile.cdc @@ -0,0 +1,394 @@ +/** Generic Profile Contract + +License: MIT + +I am trying to figure out a generic re-usable Profile Micro-Contract +that any application can consume and use. It should be easy to integrate +this contract with any application, and as a user moves from application +to application this profile can come with them. A core concept here is +given a Flow Address, a profiles details can be publically known. This +should mean that if an application were to use/store the Flow address of +a user, than this profile could be visible, and maintained with out storing +a copy in an applications own databases. I believe that anytime we can move +a common database table into a publically accessible contract/resource is a +win. + +could be a little more than that too. As Flow Accounts can now have +multiple contracts, it could be fun to allow for these accounts to have +some basic information too. https://flow-view-source.com is a side project +of mine (qvvg) and if you are looking at an account on there, or a contract +deployed to an account I will make it so it pulls info from a properly +configured Profile Resource. + +==================== +## Table of Contents +==================== + Line +Intro ......................................................... 1 +Table of Contents ............................................. 27 +General Profile Contract Info ................................. 41 +Examples ...................................................... 50 + Initializing a Profile Resource ............................. 59 + Interacting with Profile Resource (as Owner) ................ 112 + Reading a Profile Given a Flow Address ...................... 160 + Reading a Multiple Profiles Given Multiple Flow Addresses ... 192 + Checking if Flow Account is Initialized ..................... 225 + + +================================ +## General Profile Contract Info +================================ + +Currently a profile consists of a couple main pieces: + - name – An alias the profile owner would like to be refered as. + - avatar - An href the profile owner would like applications to use to represent them graphically. + - color - A valid html color (not verified in any way) applications can use to accent and personalize the experience. + - info - A short description about the account. + +=========== +## Examples +=========== + +The following examples will include both raw cadence transactions and scripts +as well as how you can call them from FCL. The FCL examples are currently assuming +the following configuration is called somewhere in your application before the +the actual calls to the chain are invoked. + +================================== +## Initializing a Profile Resource +================================== + +Initializing should be done using the paths that the contract exposes. +This will lead to predictability in how applications can look up the data. + +----------- +### Cadence +----------- + + import Profile from 0xba1132bc08f82fe2 + + transaction { + let address: address + prepare(currentUser: auth(SaveValue, PublishCapability, IssueStorageCapabilityController) &Account) { + self.address = currentUser.address + if !Profile.check(self.address) { + let profileCapability = currentUser.capabilities.storage.issue<&{Profile.Public}>(Profile.privatePath) + currentUser.capabilities.publish(profileCapability, at: Profile.publicPath) + } + } + post { + Profile.check(self.address): "Account was not initialized" + } + } + +------- +### FCL +------- + + import {query} from "@onflow/fcl" + + await mutate({ + cadence: ` + import Profile from 0xba1132bc08f82fe2 + + transaction { + prepare(currentUser: auth(SaveValue, PublishCapability, IssueStorageCapabilityController) &Account) { + self.address = currentUser.address + if !Profile.check(self.address) { + currentUser.save(<- Profile.new(), to: Profile.privatePath) + let profileCapability = currentUser.capabilities.storage.issue<&{Profile.Public}>(Profile.privatePath) + currentUser.capabilities.publish(profileCapability, at: Profile.publicPath) + } + } + post { + Profile.check(self.address): "Account was not initialized" + } + } + `, + limit: 55, + }) + +=============================================== +## Interacting with Profile Resource (as Owner) +=============================================== + +As the owner of a resource you can update the following: + - name using `.setName("MyNewName")` (as long as you arent verified) + - avatar using `.setAvatar("https://url.to.my.avatar")` + - color using `.setColor("tomato")` + - info using `.setInfo("I like to make things with Flow :wave:")` + +----------- +### Cadence +----------- + + import Profile from 0xba1132bc08f82fe2 + + transaction(name: String) { + prepare(currentUser: auth(BorrowValue) &Account) { + currentUser + .storage + .borrow<&{Profile.Owner}>(from: Profile.privatePath)! + .setName(name) + } + } + +------- +### FCL +------- + + import {mutate} from "@onflow/fcl" + + await mutate({ + cadence: ` + import Profile from 0xba1132bc08f82fe2 + + transaction(name: String) { + prepare(currentUser: auth(BorrowValue) &Account) { + currentUser + .storage + .borrow<&{Profile.Owner}>(from: Profile.privatePath)! + .setName(name) + } + } + `, + args: (arg, t) => [ + arg("qvvg", t.String), + ], + limit: 55, + }) + +========================================= +## Reading a Profile Given a Flow Address +========================================= + +----------- +### Cadence +----------- + + import Profile from 0xba1132bc08f82fe2 + + access(all) fun main(address: Address): Profile.ReadOnly? { + return Profile.read(address) + } + +------- +### FCL +------- + + import {query} from "@onflow/fcl" + + await query({ + cadence: ` + import Profile from 0xba1132bc08f82fe2 + + access(all) fun main(address: Address): Profile.ReadOnly? { + return Profile.read(address) + } + `, + args: (arg, t) => [ + arg("0xba1132bc08f82fe2", t.Address) + ] + }) + +============================================================ +## Reading a Multiple Profiles Given Multiple Flow Addresses +============================================================ + +----------- +### Cadence +----------- + + import Profile from 0xba1132bc08f82fe2 + + access(all) fun main(addresses: [Address]): {Address: Profile.ReadOnly} { + return Profile.readMultiple(addresses) + } + +------- +### FCL +------- + + import {query} from "@onflow/fcl" + + await query({ + cadence: ` + import Profile from 0xba1132bc08f82fe2 + + access(all) fun main(addresses: [Address]): {Address: Profile.ReadOnly} { + return Profile.readMultiple(addresses) + } + `, + args: (arg, t) => [ + arg(["0xba1132bc08f82fe2", "0xf76a4c54f0f75ce4", "0xf117a8efa34ffd58"], t.Array(t.Address)), + ] + }) + +========================================== +## Checking if Flow Account is Initialized +========================================== + +----------- +### Cadence +----------- + + import Profile from 0xba1132bc08f82fe2 + + access(all) fun main(address: Address): Bool { + return Profile.check(address) + } + +------- +### FCL +------- + + import {query} from "@onflow/fcl" + + await query({ + cadence: ` + import Profile from 0xba1132bc08f82fe2 + + access(all) fun main(address: Address): Bool { + return Profile.check(address) + } + `, + args: (arg, t) => [ + arg("0xba1132bc08f82fe2", t.Address) + ] + }) + +*/ +access(all) contract Profile { + access(all) let publicPath: PublicPath + access(all) let privatePath: StoragePath + + access(all) resource interface Public { + access(all) fun getName(): String + access(all) fun getAvatar(): String + access(all) fun getColor(): String + access(all) fun getInfo(): String + access(all) fun asReadOnly(): Profile.ReadOnly + } + + access(all) resource interface Owner { + access(all) fun getName(): String + access(all) fun getAvatar(): String + access(all) fun getColor(): String + access(all) fun getInfo(): String + + access(all) fun setName(_ name: String) { + pre { + name.length <= 15: "Names must be under 15 characters long." + } + } + access(all) fun setAvatar(_ src: String) + access(all) fun setColor(_ color: String) + access(all) fun setInfo(_ info: String) { + pre { + info.length <= 280: "Profile Info can at max be 280 characters long." + } + } + } + + access(all) resource Base: Owner, Public { + access(self) var name: String + access(self) var avatar: String + access(self) var color: String + access(self) var info: String + + init() { + self.name = "Anon" + self.avatar = "" + self.color = "#232323" + self.info = "" + } + + access(all) fun getName(): String { return self.name } + access(all) fun getAvatar(): String { return self.avatar } + access(all) fun getColor(): String {return self.color } + access(all) fun getInfo(): String { return self.info } + + access(all) fun setName(_ name: String) { self.name = name } + access(all) fun setAvatar(_ src: String) { self.avatar = src } + access(all) fun setColor(_ color: String) { self.color = color } + access(all) fun setInfo(_ info: String) { self.info = info } + + access(all) fun asReadOnly(): Profile.ReadOnly { + return Profile.ReadOnly( + address: self.owner?.address, + name: self.getName(), + avatar: self.getAvatar(), + color: self.getColor(), + info: self.getInfo() + ) + } + } + + access(all) struct ReadOnly { + access(all) let address: Address? + access(all) let name: String + access(all) let avatar: String + access(all) let color: String + access(all) let info: String + + init(address: Address?, name: String, avatar: String, color: String, info: String) { + self.address = address + self.name = name + self.avatar = avatar + self.color = color + self.info = info + } + } + + access(all) fun new(): @Profile.Base { + return <- create Base() + } + + access(all) fun check(_ address: Address): Bool { + return getAccount(address) + .capabilities + .get<&{Profile.Public}>(Profile.publicPath) + .check() + } + + access(all) fun fetch(_ address: Address): &{Profile.Public} { + return getAccount(address) + .capabilities + .get<&{Profile.Public}>(Profile.publicPath) + .borrow()! + } + + access(all) fun read(_ address: Address): Profile.ReadOnly? { + if let profile = getAccount(address).capabilities.get<&{Profile.Public}>(Profile.publicPath).borrow() { + return profile.asReadOnly() + } else { + return nil + } + } + + access(all) fun readMultiple(_ addresses: [Address]): {Address: Profile.ReadOnly} { + let profiles: {Address: Profile.ReadOnly} = {} + for address in addresses { + let profile = Profile.read(address) + if profile != nil { + profiles[address] = profile! + } + } + return profiles + } + + + init() { + self.publicPath = /public/profile + self.privatePath = /storage/profile + + self.account.storage.save(<- self.new(), to: self.privatePath) + let profileCapability = self.account.capabilities.storage.issue<&{Public}>(self.privatePath) + self.account.capabilities.publish(profileCapability, at: self.publicPath) + + self.account + .storage + .borrow<&{Owner}>(from: self.privatePath)! + .setName("qvvg") + } +} diff --git a/cadence/scripts/read-profile.cdc b/cadence/scripts/read-profile.cdc new file mode 100644 index 0000000..2e908df --- /dev/null +++ b/cadence/scripts/read-profile.cdc @@ -0,0 +1,5 @@ +import "Profile" + +access(all) fun main(address: Address): Profile.ReadOnly? { + return Profile.read(address) +} diff --git a/cadence/transactions/create-profile.cdc b/cadence/transactions/create-profile.cdc new file mode 100644 index 0000000..ae624f7 --- /dev/null +++ b/cadence/transactions/create-profile.cdc @@ -0,0 +1,15 @@ +import "Profile" + +transaction { + prepare(account: auth(SaveValue, PublishCapability, IssueStorageCapabilityController) &Account) { + // Only initialize the account if it hasn't already been initialized + if (!Profile.check(account.address)) { + // This creates and stores the profile in the user's account + account.storage.save(<- Profile.new(), to: Profile.privatePath) + + // This creates the public capability that lets applications read the profile's info + let profileCapability = account.capabilities.storage.issue<&{Profile.Public}>(Profile.privatePath) + account.capabilities.publish(profileCapability, at: Profile.publicPath) + } + } +} diff --git a/cadence/transactions/update-profile.cdc b/cadence/transactions/update-profile.cdc new file mode 100644 index 0000000..e6a0603 --- /dev/null +++ b/cadence/transactions/update-profile.cdc @@ -0,0 +1,20 @@ +import "Profile" + +transaction(name: String, color: String, info: String) { + prepare(account: auth(BorrowValue) &Account) { + account + .storage + .borrow<&{Profile.Owner}>(from: Profile.privatePath)! + .setName(name) + + account + .storage + .borrow<&{Profile.Owner}>(from: Profile.privatePath)! + .setInfo(info) + + account + .storage + .borrow<&{Profile.Owner}>(from: Profile.privatePath)! + .setColor(color) + } +} diff --git a/flow.json b/flow.json new file mode 100644 index 0000000..bbddd02 --- /dev/null +++ b/flow.json @@ -0,0 +1,35 @@ +{ + "emulators": { + "default": { + "port": 3569, + "serviceAccount": "emulator-account" + } + }, + "contracts": { + "Profile": { + "source": "./cadence/contracts/Profile.cdc", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "testnet": "ba1132bc08f82fe2" + } + } + }, + "networks": { + "emulator": "127.0.0.1:3569", + "mainnet": "access.mainnet.nodes.onflow.org:9000", + "testnet": "access.devnet.nodes.onflow.org:9000" + }, + "accounts": { + "emulator-account": { + "address": "f8d6e0586b0a20c7", + "key": "de98b6987469c7ba3692a2eb841c75becf66c65df19a0128398a20f0e5f9c812" + } + }, + "deployments": { + "emulator": { + "emulator-account": [ + "Profile" + ] + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 4864974..7fbc2d1 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,14 @@ "preview": "vite preview" }, "devDependencies": { - "@onflow/fcl": "^1.3.2", - "@picocss/pico": "^1.5.0", - "@rollup/plugin-inject": "^5.0.3", - "@sveltejs/adapter-auto": "^1.0.1", - "@sveltejs/kit": "^1.1.1", - "svelte": "^3.55.1", - "vite": "^4.0.4", - "vite-plugin-node-polyfills": "^0.7.0" + "@onflow/fcl": "^1.12.3", + "@picocss/pico": "^2.0.6", + "@rollup/plugin-inject": "^5.0.5", + "@sveltejs/adapter-auto": "^3.3.1", + "@sveltejs/kit": "^2.8.1", + "svelte": "^5.1.16", + "vite": "^5.4.11", + "vite-plugin-node-polyfills": "^0.22.0" }, "type": "module" } diff --git a/src/flow/actions.js b/src/flow/actions.js index b874348..e4344f8 100644 --- a/src/flow/actions.js +++ b/src/flow/actions.js @@ -1,5 +1,8 @@ import { browser } from '$app/environment'; import { get } from 'svelte/store'; +import ReadProfileScript from '../../cadence/scripts/read-profile.cdc?raw' +import CreateProfileTransaction from '../../cadence/transactions/create-profile.cdc?raw' +import UpdateProfileTransaction from '../../cadence/transactions/update-profile.cdc?raw' import * as fcl from "@onflow/fcl"; import "./config"; @@ -23,22 +26,7 @@ export const initAccount = async () => { try { transactionId = await fcl.mutate({ - cadence: ` - import Profile from 0xProfile - - transaction { - prepare(account: AuthAccount) { - // Only initialize the account if it hasn't already been initialized - if (!Profile.check(account.address)) { - // This creates and stores the profile in the user's account - account.save(<- Profile.new(), to: Profile.privatePath) - - // This creates the public capability that lets applications read the profile's info - account.link<&Profile.Base{Profile.Public}>(Profile.publicPath, target: Profile.privatePath) - } - } - } - `, + cadence: CreateProfileTransaction, payer: fcl.authz, proposer: fcl.authz, authorizations: [fcl.authz], @@ -66,13 +54,7 @@ export const sendQuery = async (addr) => { try { profileQueryResult = await fcl.query({ - cadence: ` - import Profile from 0xProfile - - pub fun main(address: Address): Profile.ReadOnly? { - return Profile.read(address) - } - `, + cadence: ReadProfileScript, args: (arg, t) => [arg(addr, t.Address)] }) console.log(profileQueryResult) @@ -87,25 +69,7 @@ export const executeTransaction = async () => { initTransactionState() try { const transactionId = await fcl.mutate({ - cadence: ` - import Profile from 0xProfile - - transaction(name: String, color: String, info: String) { - prepare(account: AuthAccount) { - account - .borrow<&Profile.Base{Profile.Owner}>(from: Profile.privatePath)! - .setName(name) - - account - .borrow<&Profile.Base{Profile.Owner}>(from: Profile.privatePath)! - .setInfo(info) - - account - .borrow<&Profile.Base{Profile.Owner}>(from: Profile.privatePath)! - .setColor(color) - } - } - `, + cadence: UpdateProfileTransaction, args: (arg, t) => [ arg(get(profile).name, t.String), arg(get(profile).color, t.String), diff --git a/src/flow/config.js b/src/flow/config.js index e38989a..1a86659 100644 --- a/src/flow/config.js +++ b/src/flow/config.js @@ -1,9 +1,10 @@ import { config } from "@onflow/config"; +import flowJSON from '../../flow.json'; config({ "app.detail.title": "FCL Quickstart for SvelteKit", // Shows user what dapp is trying to connect "app.detail.icon": "https://unavatar.io/twitter/muttonia", // shows image to the user to display your dapp brand "accessNode.api": import.meta.env.VITE_ACCESS_NODE_API, "discovery.wallet": import.meta.env.VITE_DISCOVERY_WALLET, - "0xProfile": import.meta.env.VITE_PROFILE_ADDRESS -}) \ No newline at end of file + "flow.network": import.meta.env.VITE_FLOW_NETWORK, +}).load({ flowJSON }) \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index b6b70f2..8eded85 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,9 +10,9 @@ const config = { }, plugins: [ nodePolyfills({ - // Whether to polyfill `node:` protocol imports. - protocolImports: true, - }), + // Whether to polyfill `node:` protocol imports. + protocolImports: true, + }), sveltekit() ], resolve:{ @@ -25,5 +25,11 @@ const config = { plugins: [inject({ Buffer: ['buffer', 'Buffer'] })] }, }, + server: { + fs: { + // Allow serving files from one level up to the project root + allow: ['./flow.json', './cadence'], + }, + }, }; export default config; \ No newline at end of file