├── .editorconfig ├── .gitattributes ├── .gitignore ├── .prettierrc ├── .yarn ├── install-state.gz └── releases │ └── yarn-3.6.0.cjs ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── apps │ ├── backend │ │ ├── cloudflare-notification-worker │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── index.ts │ │ │ │ └── minimum.ts │ │ │ ├── tsconfig.json │ │ │ └── wrangler.toml │ │ ├── discord-checkout-bot │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── commands.ts │ │ │ │ ├── config.ts │ │ │ │ ├── database.ts │ │ │ │ ├── index.ts │ │ │ │ └── unlock.ts │ │ │ └── tsconfig.json │ │ └── discord-webhook │ │ │ ├── .gitignore │ │ │ ├── .template.env │ │ │ ├── README.md │ │ │ ├── SECURITY.md │ │ │ ├── package.json │ │ │ ├── src │ │ │ ├── config.ts │ │ │ ├── middleware.ts │ │ │ ├── server.ts │ │ │ └── util.ts │ │ │ └── tsconfig.json │ ├── nextjs │ │ └── gating │ │ │ ├── .env.template │ │ │ ├── .eslintrc.json │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── next-env.d.ts │ │ │ ├── next.config.js │ │ │ ├── package.json │ │ │ ├── postcss.config.js │ │ │ ├── public │ │ │ ├── favicon.ico │ │ │ └── vercel.svg │ │ │ ├── src │ │ │ ├── config │ │ │ │ ├── session.ts │ │ │ │ ├── site.ts │ │ │ │ └── unlock.ts │ │ │ ├── hooks │ │ │ │ └── useUser.ts │ │ │ ├── pages │ │ │ │ ├── _app.tsx │ │ │ │ ├── api │ │ │ │ │ ├── login.ts │ │ │ │ │ ├── logout.ts │ │ │ │ │ ├── memberships.ts │ │ │ │ │ └── user.ts │ │ │ │ ├── index.tsx │ │ │ │ └── types.d.ts │ │ │ ├── styles │ │ │ │ └── globals.css │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ │ ├── tailwind.config.js │ │ │ └── tsconfig.json │ ├── react │ │ ├── ticket-chat │ │ │ ├── README.md │ │ │ ├── index.html │ │ │ ├── netlify.toml │ │ │ ├── package.json │ │ │ ├── postcss.config.cjs │ │ │ ├── public │ │ │ │ ├── home.png │ │ │ │ └── vite.svg │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── assets │ │ │ │ │ └── react.svg │ │ │ │ ├── components │ │ │ │ │ ├── BlockAvatar.tsx │ │ │ │ │ ├── Navigation.tsx │ │ │ │ │ └── QRCodeDrop.tsx │ │ │ │ ├── config │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── liveblock.ts │ │ │ │ │ └── networks.ts │ │ │ │ ├── hooks │ │ │ │ │ └── useUser.ts │ │ │ │ ├── index.css │ │ │ │ ├── main.tsx │ │ │ │ ├── pages │ │ │ │ │ ├── Home.tsx │ │ │ │ │ └── Room.tsx │ │ │ │ ├── utils │ │ │ │ │ ├── ticket.ts │ │ │ │ │ └── unlock.ts │ │ │ │ └── vite-env.d.ts │ │ │ ├── tailwind.config.cjs │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.node.json │ │ │ └── vite.config.ts │ │ └── wagmi.sh │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── postcss.config.js │ │ │ ├── public │ │ │ ├── favicon.ico │ │ │ ├── index.html │ │ │ ├── manifest.json │ │ │ └── robots.txt │ │ │ ├── src │ │ │ ├── App.css │ │ │ ├── App.js │ │ │ ├── DeployLock.js │ │ │ ├── PurchaseKey.js │ │ │ ├── index.css │ │ │ ├── index.js │ │ │ └── setupTests.js │ │ │ └── tailwind.config.js │ └── unlockjs │ │ └── subgraph-service.md ├── paywall │ ├── magic │ │ ├── .env.local.example │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── lib │ │ │ ├── UserContext.js │ │ │ └── magic.js │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages │ │ │ ├── _app.js │ │ │ ├── _document.js │ │ │ ├── api │ │ │ │ └── login.js │ │ │ ├── dashboard.js │ │ │ ├── index.js │ │ │ └── login.js │ │ ├── public │ │ │ └── favicon.ico │ │ └── styles │ │ │ └── globals.css │ ├── provider │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── README.md │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── index.tsx │ │ │ └── profile.tsx │ │ └── vite.config.js │ ├── vanilla-js │ │ ├── .gitignore │ │ ├── README.md │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ └── vite.svg │ │ ├── src │ │ │ ├── checkout.ts │ │ │ ├── main.ts │ │ │ ├── style.css │ │ │ ├── typescript.svg │ │ │ └── vite-env.d.ts │ │ └── tsconfig.json │ └── wagmi │ │ ├── .eslintrc.cjs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ └── vite.svg │ │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── Profile.tsx │ │ ├── assets │ │ │ └── react.svg │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts └── solidity │ └── hooks │ ├── discount-hook │ ├── .gitignore │ ├── .yarn │ │ └── install-state.gz │ ├── README.md │ ├── contracts │ │ └── DiscountHook.sol │ ├── hardhat.config.ts │ ├── package.json │ ├── renovate.json │ ├── scripts │ │ └── deploy.ts │ ├── test │ │ └── DiscountHook.ts │ └── tsconfig.json │ └── guild-hook │ ├── .gitignore │ ├── README.md │ ├── contracts │ └── GuildHook.sol │ ├── hardhat.config.ts │ ├── package.json │ ├── scripts │ └── deploy.ts │ ├── test │ └── GuildHook.ts │ └── tsconfig.json ├── package.json ├── turbo.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 27 | 28 | # dependencies 29 | /.pnp 30 | .pnp.js 31 | 32 | # testing 33 | /coverage 34 | 35 | # next.js 36 | /.next/ 37 | /out/ 38 | 39 | # production 40 | /build 41 | 42 | # misc 43 | .DS_Store 44 | *.pem 45 | 46 | # local env files 47 | .env.local 48 | .env.development.local 49 | .env.test.local 50 | .env.production.local 51 | 52 | # vercel 53 | .vercel 54 | 55 | # typescript 56 | *.tsbuildinfo 57 | .turbo 58 | .next 59 | .yarn/cache 60 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "semi": false 5 | } -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unlock-protocol/examples/d5e95417ad35f75466b3c6cf16e71dbc2fe495b8/.yarn/install-state.gz -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.6.0.cjs 2 | nodeLinker: node-modules 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hello@unlock-protocol.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Unlock thrives on your contributions. We're excited you are here! Whether you are a software developer, a designer, a writer, or a tester, you are welcome. You can find our contributor guidelines on our [wiki](https://github.com/unlock-protocol/unlock/wiki/Getting-Started). 4 | 5 | Please follow above with respect to this repo. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Unlock Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unlock Examples 2 | 3 | This repository contains examples and sample applications built using unlock protocol in different contexts. You can use the examples here as a starting point for building your own applications! 4 | 5 | Please, do check the [Unlock Protocols docs](https://docs.unlock-protocol.com/) for extensive documentation. 6 | 7 | The examples are loosely sorted by languages and framework used. Each of them contains a README that provides more details about what the specific example provides. 8 | 9 | ## Solidity 10 | 11 | ### Hooks 12 | 13 | - [Discount Hook](./solidity/hooks/discount-hook/) 14 | 15 | A basic hook example to set special discounted price for specific addresses when they purchase a membership 16 | 17 | ## Javascript 18 | 19 | ### Nodejs 20 | 21 | - [Discord Checkout Bot](./javascript/node.js/discord-checkout-bot/) 22 | 23 | A token gating discord bot which allows creators to sell NFT and give access to their discord. This example makes use of unlock checkout and authentication APIs to integrate with discord. 24 | 25 | - [Discord Webhook](./javascript/node.js/discord-webhook) 26 | 27 | A discord webhook to receive information about newly created keys and locks on different networks supported by unlock on discord channels. This example makes use of websub APIs provided by unlock to subscribe and receive events from locksmith. 28 | 29 | ### React 30 | 31 | - [Ticket Chat](./javascript/react/ticket-chat) 32 | 33 | A realtime chat application built on top of [liveblocks](https://liveblocks.io) to allow unlock NFT ticket holders to chat with each other at events. 34 | This example makes use of unlock QR code tickets and SIWE integration to build a smooth token gating event chat flow. 35 | 36 | - [Wagmi.sh](./javascript/react//wagmi.sh/) 37 | 38 | ### Wagmi 39 | 40 | A basic react application ([create-react-app](https://create-react-app.dev/)) that shows how to use [Wagmi.sh](https://wagmi.sh/) with Unlock. 41 | 42 | ### Nextjs 43 | 44 | - [Nextjs Gating](./javascript/nextjs/token-gating/) 45 | 46 | A nextjs gating template app which uses server side gating and access control based on tier of memberships. This example makes of use unlock checkout and authentication APIs to provide server-side gating and access control. 47 | 48 | ## Unlockjs 49 | 50 | ### Subgraph-Service 51 | 52 | - [Subgraph service](./javascript/unlockjs/subgraph-service.md) 53 | 54 | Using the SubgraphService to query locks. 55 | 56 | # License 57 | 58 | All code is licensed under [MIT](./LICENSE) 59 | -------------------------------------------------------------------------------- /examples/apps/backend/cloudflare-notification-worker/README.md: -------------------------------------------------------------------------------- 1 | # Notification worker 2 | 3 | This is a simple notification worker that can be used to send notifications on discord when network balance on hosted locksmith is low. 4 | 5 | ## Setup 6 | 7 | 1. Run yarn install 8 | 2. Setup environment variables using wrangler 9 | 10 | ```bash 11 | wrangler secret put DISCORD_WEBHOOK_URL # https://discord.com/api/webhooks/... 12 | wrangler secret put LOCKSMITH_URL # https://locksmith.unlock-protocol.com 13 | wrangler secret put DISCORD_USER_ID # 1234567890 14 | ``` 15 | 16 | 3. Run wrangler publish 17 | 18 | If running in development, use `wrangler dev --test-scheduled` to run the worker locally. This will run the worker every 5 minutes. 19 | -------------------------------------------------------------------------------- /examples/apps/backend/cloudflare-notification-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-notification-worker", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "@cloudflare/workers-types": "3.16.0", 6 | "typescript": "4.8.4", 7 | "wrangler": "2.6.1" 8 | }, 9 | "private": true, 10 | "scripts": { 11 | "start": "wrangler dev", 12 | "deploy": "wrangler publish" 13 | }, 14 | "dependencies": { 15 | "@unlock-protocol/networks": "^0.0.5", 16 | "bignumber.js": "^9.1.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/apps/backend/cloudflare-notification-worker/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Welcome to Cloudflare Workers! This is your first scheduled worker. 3 | * 4 | * - Run `wrangler dev --local` in your terminal to start a development server 5 | * - Run `curl "http://localhost:8787/cdn-cgi/mf/scheduled"` to trigger the scheduled event 6 | * - Go back to the console to see what your worker has logged 7 | * - Update the Cron trigger in wrangler.toml (see https://developers.cloudflare.com/workers/wrangler/configuration/#triggers) 8 | * - Run `wrangler publish --name my-worker` to publish your worker 9 | * 10 | * Learn more at https://developers.cloudflare.com/workers/runtime-apis/scheduled-event/ 11 | */ 12 | import BigNumber from 'bignumber.js' 13 | import { networks } from '@unlock-protocol/networks' 14 | import { MINIMUM_BALANCES } from './minimum' 15 | export interface Env { 16 | // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ 17 | // MY_KV_NAMESPACE: KVNamespace; 18 | // 19 | // Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/ 20 | // MY_DURABLE_OBJECT: DurableObjectNamespace; 21 | // 22 | // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ 23 | // MY_BUCKET: R2Bucket; 24 | DISCORD_WEBHOOK_URL: string 25 | DISCORD_USER_ID: string 26 | LOCKSMITH_URL: string 27 | NOTIFICATIONS: KVNamespace 28 | } 29 | 30 | interface Options { 31 | network: Record 32 | address: string 33 | balance: string 34 | minimum: string 35 | user: string 36 | } 37 | 38 | async function sendNotification(endpoint: string, options: Options) { 39 | const { network, address, balance, minimum, user } = options 40 | const body = { 41 | username: 'Unlock Alert', 42 | allowed_mentions: { 43 | parse: ['users'], 44 | }, 45 | content: `<@${user}>`, 46 | embeds: [ 47 | { 48 | title: `Low balance on ${network.name} network`, 49 | type: 'rich', 50 | description: `The balance on ${network.name} network is ${balance} which is lower the minimum threshold set at ${minimum}. Please fund it ASAP.`, 51 | fields: [ 52 | { 53 | name: 'Network', 54 | value: network.name, 55 | inline: true, 56 | }, 57 | { 58 | name: 'Network ID', 59 | value: network.id, 60 | inline: true, 61 | }, 62 | { 63 | name: 'Address', 64 | value: address, 65 | inline: true, 66 | }, 67 | { 68 | name: 'Balance', 69 | value: balance, 70 | inline: true, 71 | }, 72 | ], 73 | }, 74 | ], 75 | } 76 | 77 | const response = await fetch(endpoint, { 78 | headers: { 79 | 'content-type': 'application/json', 80 | }, 81 | body: JSON.stringify(body), 82 | method: 'POST', 83 | }) 84 | 85 | if (!response.ok) { 86 | console.error(response, options, response.statusText, response.status) 87 | } else { 88 | console.info(response, options, 'successfully sent notification') 89 | } 90 | } 91 | 92 | export default { 93 | async scheduled( 94 | controller: ScheduledController, 95 | env: Env, 96 | ctx: ExecutionContext 97 | ): Promise { 98 | const balanceEndpoint = new URL('/purchase', env.LOCKSMITH_URL) 99 | const lastSent = await env.NOTIFICATIONS.get('last_sent') 100 | // If sent a notification in the last 24 hours, do not send another one 101 | if (lastSent) { 102 | const lastSentDate = parseInt(lastSent) 103 | const diff = Date.now() - lastSentDate 104 | if (diff < 1000 * 60 * 60 * 24) { 105 | return 106 | } 107 | } 108 | 109 | const response = await fetch(balanceEndpoint.toString(), { 110 | method: 'GET', 111 | headers: { 112 | 'content-type': 'application/json', 113 | }, 114 | }) 115 | 116 | const balances: Record< 117 | string, 118 | Record 119 | > = await response.json() 120 | 121 | for (const [networkId, config] of Object.entries(balances)) { 122 | if (!Object.keys(config).length) { 123 | continue 124 | } 125 | 126 | const { balance, address } = config 127 | const network = networks[networkId] 128 | const minimumBalance = new BigNumber(MINIMUM_BALANCES[networkId]) 129 | const networkBalance = new BigNumber(balance) 130 | if (networkBalance.gte(minimumBalance)) { 131 | continue 132 | } 133 | 134 | await sendNotification(env.DISCORD_WEBHOOK_URL, { 135 | network, 136 | address, 137 | balance: networkBalance.toString(), 138 | minimum: minimumBalance.toString(), 139 | user: env.DISCORD_USER_ID, 140 | }) 141 | await env.NOTIFICATIONS.put('last_sent', Date.now()?.toString()) 142 | } 143 | }, 144 | async fetch() { 145 | return new Response('Hello!') 146 | }, 147 | } 148 | -------------------------------------------------------------------------------- /examples/apps/backend/cloudflare-notification-worker/src/minimum.ts: -------------------------------------------------------------------------------- 1 | export const MINIMUM_BALANCES: Record = { 2 | // Roughly 50 USD on each? 3 | '1': '0.05', 4 | '5': '0.05', 5 | '137': '50', 6 | '43114': '3', 7 | '42161': '0.05', 8 | '56': '0.2', 9 | '42220': '100', 10 | '100': '50', 11 | '80001': '0', // NA 12 | '10': '0.05', 13 | } 14 | -------------------------------------------------------------------------------- /examples/apps/backend/cloudflare-notification-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "es2021" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | "jsx": "react" /* Specify what JSX code is generated. */, 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 24 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | 28 | /* Modules */ 29 | "module": "es2022" /* Specify what module code is generated. */, 30 | // "rootDir": "./", /* Specify the root folder within your source files. */ 31 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 35 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 36 | "types": [ 37 | "@cloudflare/workers-types" 38 | ] /* Specify type package names to be included without being referenced in a source file. */, 39 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 40 | "resolveJsonModule": true /* Enable importing .json files */, 41 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 45 | "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 47 | 48 | /* Emit */ 49 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 50 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 51 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 52 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 54 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 55 | // "removeComments": true, /* Disable emitting comments. */ 56 | "noEmit": true /* Disable emitting files from a compilation. */, 57 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 58 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 59 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 60 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 63 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 64 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 65 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 66 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 67 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 68 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 69 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 70 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 71 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 72 | 73 | /* Interop Constraints */ 74 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 75 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 76 | // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 79 | 80 | /* Type Checking */ 81 | "strict": true /* Enable all strict type-checking options. */, 82 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 83 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 84 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 85 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 86 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 87 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 88 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 89 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 90 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 91 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 92 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 93 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 94 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 95 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 96 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 97 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 98 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 99 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 100 | 101 | /* Completeness */ 102 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 103 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /examples/apps/backend/cloudflare-notification-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "notification-example" 2 | main = "src/index.ts" 3 | compatibility_date = "2022-10-10" 4 | 5 | kv_namespaces = [ 6 | { binding = "NOTIFICATIONS", id = "0c65203ee18449778e0f2e9c852ed76b", preview_id = "728ba76dfd934708ba31fd7c90ebdec9" } 7 | ] 8 | 9 | [triggers] 10 | crons = [ "*/5 * * * *" ] 11 | 12 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/README.md: -------------------------------------------------------------------------------- 1 | # Discord Checkout Bot 2 | 3 | This is the checkout bot used by unlock to token-gate and sell memberships on the discord. 4 | 5 | ## Develop 6 | 7 | 1. Install all the dependencies. 8 | 9 | ```sh 10 | yarn install 11 | ``` 12 | 13 | 1. Configure your lock and paywall config in the config.ts file 14 | 15 | 1. Create a .env.local file with the following variables. You will need to create a discord bot application from the discord developer panel. 16 | 17 | ```sh 18 | DISCORD_CLIENT_ID= 19 | DISCORD_CHANNEL_ID= 20 | DISCORD_GUILD_ID= 21 | DISCORD_ROLE_ID= 22 | DISCORD_CLIENT_SECRET= 23 | DISCORD_BOT_TOKEN= 24 | HOST= 25 | DATABASE_URL= 26 | ``` 27 | 28 | 1. Run `yarn dev` to start the bot. 29 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-checkout-bot", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "private": true, 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@types/node": "^17.0.31", 9 | "dotenv": "^16.0.0", 10 | "dotenv-cli": "^5.1.0", 11 | "ts-node-dev": "^1.1.8", 12 | "typescript": "^4.6.4" 13 | }, 14 | "dependencies": { 15 | "@discordjs/builders": "^1.6.3", 16 | "@discordjs/rest": "^0.4.1", 17 | "@fastify/cookie": "^6.0.0", 18 | "@unlock-protocol/unlock-js": "^0.23.1", 19 | "discord-api-types": "^0.32.1", 20 | "discord-oauth2": "^2.10.0", 21 | "discord.js": "^13.6.0", 22 | "ethers": "^5.6.5", 23 | "fastify": "^3.29.0", 24 | "pg": "^8.7.3", 25 | "pg-hstore": "^2.3.4", 26 | "sequelize": "^6.19.0", 27 | "zod": "^3.14.4" 28 | }, 29 | "scripts": { 30 | "build": "tsc -p .", 31 | "postinstall": "yarn build", 32 | "start": "node build/index.js", 33 | "dev": "dotenv -e .env.local ts-node-dev src/index.ts" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/src/commands.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | 3 | export const commands = [ 4 | new SlashCommandBuilder() 5 | .setName("ping") 6 | .setDescription("Replies with pong!"), 7 | new SlashCommandBuilder() 8 | .setName("unlock") 9 | .setDescription("Unlock ability to send messages in discord"), 10 | ].map((command) => command.toJSON()); 11 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/src/config.ts: -------------------------------------------------------------------------------- 1 | export const paywallConfig = { 2 | messageToSign: 'Allow access to Unlock Discord Community', 3 | pessimistic: true, 4 | locks: { 5 | '0xb6bd8fc42df6153f79eea941a2b4c86f8e5f7b1d': { 6 | name: 'Unlock Community', 7 | network: 8453, 8 | }, 9 | }, 10 | metadataInputs: [{ name: 'email', type: 'email', required: true }], 11 | } 12 | 13 | export const config = { 14 | paywallConfig, 15 | clientId: process.env.DISCORD_CLIENT_ID!, 16 | clientSecret: process.env.DISCORD_CLIENT_SECRET!, 17 | host: process.env.HOST!, 18 | token: process.env.DISCORD_BOT_TOKEN!, 19 | databaseURL: process.env.DATABASE_URL!, 20 | guildId: process.env.DISCORD_GUILD_ID!, 21 | roleId: process.env.DISCORD_ROLE_ID!, 22 | channelId: process.env.DISCORD_CHANNEL_ID!, 23 | } 24 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/src/database.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Sequelize, ModelDefined, Model } from "sequelize"; 2 | import { config } from "./config"; 3 | 4 | export const sequelize = new Sequelize( 5 | config.databaseURL!, 6 | process.env.ON_HEROKU 7 | ? { 8 | ssl: true, 9 | dialectOptions: { 10 | ssl: { 11 | require: true, 12 | rejectUnauthorized: false, 13 | }, 14 | }, 15 | } 16 | : {} 17 | ); 18 | 19 | export interface User { 20 | id: string; 21 | walletAddresses: string[]; 22 | } 23 | 24 | export const User = sequelize.define>("Users", { 25 | id: { 26 | type: DataTypes.STRING, 27 | allowNull: false, 28 | primaryKey: true, 29 | }, 30 | walletAddresses: { 31 | type: DataTypes.ARRAY(DataTypes.STRING), 32 | allowNull: false, 33 | }, 34 | }); 35 | 36 | export interface Nounce { 37 | id: string; 38 | userId?: string; 39 | } 40 | 41 | export const Nounce = sequelize.define>("Nounce", { 42 | id: { 43 | type: DataTypes.STRING, 44 | allowNull: false, 45 | primaryKey: true, 46 | }, 47 | userId: { 48 | type: DataTypes.STRING, 49 | allowNull: true, 50 | }, 51 | }); 52 | 53 | export async function appendWalletAddress( 54 | userId: string, 55 | walletAddress: string 56 | ) { 57 | const user = await User.findOne({ 58 | where: { 59 | id: userId, 60 | }, 61 | }); 62 | 63 | if (!user) { 64 | return User.create({ 65 | id: userId, 66 | walletAddresses: [walletAddress], 67 | }); 68 | } 69 | return user.update( 70 | { 71 | walletAddresses: [ 72 | ...new Set([...user.toJSON().walletAddresses, walletAddress]), 73 | ], 74 | }, 75 | { 76 | where: { 77 | id: userId, 78 | }, 79 | } 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/src/unlock.ts: -------------------------------------------------------------------------------- 1 | import { Web3Service } from "@unlock-protocol/unlock-js"; 2 | 3 | export const web3Service = new Web3Service({ 4 | "56": { 5 | publicProvider: "https://bsc-dataseed.binance.org/", 6 | provider: "https://bsc-dataseed.binance.org/", 7 | unlockAddress: "0xeC83410DbC48C7797D2f2AFe624881674c65c856", 8 | id: 56, 9 | name: "Binance Smart Chain", 10 | }, 11 | "4": { 12 | publicProvider: 13 | "https://eth-rinkeby.alchemyapi.io/v2/n0NXRSZ9olpkJUPDLBC00Es75jaqysyT", 14 | provider: 15 | "https://eth-rinkeby.alchemyapi.io/v2/n0NXRSZ9olpkJUPDLBC00Es75jaqysyT", 16 | unlockAddress: "0xd8c88be5e8eb88e38e6ff5ce186d764676012b0b", 17 | id: 4, 18 | name: "Rinkeby", 19 | }, 20 | "10": { 21 | publicProvider: "https://mainnet.optimism.io", 22 | provider: "https://mainnet.optimism.io", 23 | unlockAddress: "0x99b1348a9129ac49c6de7F11245773dE2f51fB0c", 24 | id: 10, 25 | name: "Optimism", 26 | }, 27 | "1": { 28 | id: 1, 29 | publicProvider: 30 | "https://eth-mainnet.alchemyapi.io/v2/6idtzGwDtRbzil3s6QbYHr2Q_WBfn100", // Should we use Infura? 31 | provider: 32 | "https://eth-mainnet.alchemyapi.io/v2/6idtzGwDtRbzil3s6QbYHr2Q_WBfn100", 33 | unlockAddress: "0x3d5409CcE1d45233dE1D4eBDEe74b8E004abDD13", 34 | name: "Ethereum", 35 | }, 36 | "137": { 37 | publicProvider: "https://polygon-rpc.com/", 38 | provider: 39 | "https://snowy-weathered-waterfall.matic.quiknode.pro/5b11a0413a62a295070c0dfb25637d5f8c591aba/", 40 | unlockAddress: "0xE8E5cd156f89F7bdB267EabD5C43Af3d5AF2A78f", 41 | serializerAddress: "0x646e373eaf8a4aec31bf62b7fd6fb59296d6cda9", 42 | id: 137, 43 | name: "Polygon", 44 | }, 45 | "100": { 46 | publicProvider: "https://rpc.gnosischain.com", 47 | provider: 48 | "https://cool-empty-bird.xdai.quiknode.pro/4edba942fb43c718f24480484684e907fe3fe1d3/", 49 | unlockAddress: "0x1bc53f4303c711cc693F6Ec3477B83703DcB317f", 50 | serializerAddress: "0x646E373EAf8a4AEc31Bf62B7Fd6fB59296d6CdA9", 51 | id: 100, 52 | name: "xDai", 53 | }, 54 | }); 55 | 56 | export async function hasMembership(userAddress: string, paywallConfig: any) { 57 | for (const [lockAddress, { network }] of Object.entries<{ network: number }>( 58 | paywallConfig.locks 59 | )) { 60 | const keyId = await web3Service.getTokenIdForOwner( 61 | lockAddress, 62 | userAddress, 63 | network 64 | ); 65 | if (keyId > 0) { 66 | return true; 67 | } 68 | } 69 | return false; 70 | } 71 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-checkout-bot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "Node", 5 | "module": "CommonJS", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "alwaysStrict": true, 9 | "strict": true, 10 | "rootDir": "src", 11 | "outDir": "build", 12 | "skipLibCheck": true 13 | } 14 | } -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/.template.env: -------------------------------------------------------------------------------- 1 | WEBSUB_SECRET=secret 2 | DISCORD_WEBHOOK_ID=ID 3 | DISCORD_WEBHOOK_TOKEN=token 4 | BASE_URL=http://localhost:5050 5 | PORT=5050 6 | LOCKSMITH_URL=http://localhost:8080 -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/README.md: -------------------------------------------------------------------------------- 1 | # Websub Discord 2 | 3 | This is an example application which receives events from locksmith websub endpoints and posts them to a Discord channel using webhook. 4 | 5 | ## Develop 6 | 7 | Rename .template.env to .env file and fill all the keys with correct values. 8 | 9 | You will need to create a hook for locksmith to receive events on your callback endpoint. 10 | 11 | ## Develop 12 | 13 | 1. Install all the dependencies. 14 | 15 | ```sh 16 | yarn install 17 | ``` 18 | 19 | 2. Run `yarn dev` inside the project directory. This will automatically load the environment variables and kickstart the server. 20 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Vulnerability Disclosure Policy 2 | 3 | ## Introduction 4 | 5 | Security is core to our values, and we value the input of hackers acting in good faith to help us maintain a high standard for the security and privacy for our users. This includes encouraging responsible vulnerability research and disclosure. This policy sets out our definition of good faith in the context of finding and reporting vulnerabilities, as well as what you can expect from us in return. 6 | 7 | ## Expectations 8 | 9 | When working with us according to this policy, you can expect us to: 10 | 11 | - Extend Safe Harbor for your vulnerability research that is related to this policy; 12 | - Work with you to understand and validate your report, including a timely initial response to the submission; 13 | - Work to remediate discovered vulnerabilities in a timely manner; and 14 | - Recognize your contribution to improving our security if you are the first to report a unique vulnerability, and your report triggers a code or configuration change. 15 | 16 | ## Ground Rules 17 | 18 | To encourage vulnerability research and to avoid any confusion between good-faith hacking and malicious attack, we ask that you: 19 | 20 | - Play by the rules. This includes following this policy, as well as any other relevant agreements. If there is any inconsistency between this policy and any other relevant terms, the terms of this policy will prevail; 21 | - Report any vulnerability you’ve discovered promptly; 22 | - Avoid violating the privacy of others, disrupting our systems, destroying data, and/or harming user experience; 23 | - Use only the Official Channels to discuss vulnerability information with us; 24 | - Keep the details of any discovered vulnerabilities confidential until they are fixed, according to the Disclosure Policy; 25 | - Perform testing only on in-scope systems, and respect systems and activities which are out-of-scope; 26 | - If a vulnerability provides unintended access to data: Limit the amount of data you access to the minimum required for effectively demonstrating a Proof of Concept; and cease testing and submit a report immediately if you encounter any user data during testing, such as Personally Identifiable Information (PII), Personal Healthcare Information (PHI), credit card data, or proprietary information; 27 | - You should only interact with test accounts you own or with explicit permission from the account holder; and 28 | - Do not engage in extortion. 29 | 30 | ## Safe Harbor 31 | 32 | When conducting vulnerability research according to this policy, we consider this research to be: 33 | 34 | - Authorized in accordance with the Computer Fraud and Abuse Act (CFAA) (and/or similar state laws), and we will not initiate or support legal action against you for accidental, good faith violations of this policy; 35 | - Exempt from the Digital Millennium Copyright Act (DMCA), and we will not bring a claim against you for circumvention of technology controls; 36 | - Exempt from restrictions in our Terms & Conditions that would interfere with conducting security research, and we waive those restrictions on a limited basis for work done under this policy; and 37 | - Lawful, helpful to the overall security of the Internet, and conducted in good faith. 38 | 39 | You are expected, as always, to comply with all applicable laws. 40 | 41 | If at any time you have concerns or are uncertain whether your security research is consistent with this policy, please submit a report through one of our Official Channels before going any further. 42 | 43 | ## Official Channels 44 | 45 | Please email security@unlock-protocol.com for all communications. Do not use other channels such as Twitter, Telegram, or Reddit. 46 | 47 | ## About This Policy 48 | 49 | This policy is based on the open source vulnerability disclosure policy at http://disclose.io/. -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-webhook", 3 | "packageManager": "yarn@3.1.0-rc.7", 4 | "dependencies": { 5 | "@unlock-protocol/networks": "^0.0.16", 6 | "cross-fetch": "^3.1.5", 7 | "discord.js": "^13.6.0", 8 | "express": "^4.17.2" 9 | }, 10 | "devDependencies": { 11 | "@types/express": "^4.17.13", 12 | "@types/node": "^17.0.10", 13 | "dotenv": "^14.2.0", 14 | "ts-node-dev": "^1.1.8", 15 | "typescript": "^4.5.5" 16 | }, 17 | "scripts": { 18 | "tsc": "./node_modules/typescript/bin/tsc", 19 | "dev": "tsnd -r dotenv/config src/server.ts", 20 | "build": "tsc -p .", 21 | "postinstall": "yarn build", 22 | "start": "node build/server.js" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/src/config.ts: -------------------------------------------------------------------------------- 1 | const baseURL = process.env.BASE_URL 2 | const locksmithURL = process.env.LOCKSMITH_URL || '' 3 | 4 | export const config = { 5 | id: process.env.DISCORD_WEBHOOK_ID!, 6 | token: process.env.DISCORD_WEBHOOK_TOKEN!, 7 | websubSecret: process.env.WEBSUB_SECRET!, 8 | locksmithURL, 9 | baseURL, 10 | leaseSeconds: 86400 * 90, // 90 days 11 | } 12 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express' 2 | import { createSignature } from './util' 3 | 4 | interface CreateWebsubMiddlewareOptions { 5 | secret?: string 6 | } 7 | 8 | export function createWebsubMiddleware({ 9 | secret, 10 | }: CreateWebsubMiddlewareOptions) { 11 | return (req: Request, res: Response, next: NextFunction) => { 12 | const sig = req.headers['x-hub-signature'] as string 13 | if (secret && !sig) { 14 | return res 15 | .status(302) 16 | .send('No x-hub-signature header with valid signature provided.') 17 | } 18 | const [algorithm, signature] = sig.split('=') 19 | const computedSignature = createSignature({ 20 | secret, 21 | algorithm, 22 | content: JSON.stringify(req.body), 23 | }) 24 | if (computedSignature === signature) { 25 | res.status(200).send('Received!') 26 | return next() 27 | } else { 28 | return res.status(301).send('Invalid signature.') 29 | } 30 | } 31 | } 32 | 33 | // TODO: Add a way to auto subscribe topics and put checks topic for that here. 34 | export function createIntentHandler({ secret }: { secret: string }) { 35 | return (request: Request, response: Response) => { 36 | const challenge = request.query['hub.challenge'] 37 | const requestSecret = request.query['hub.secret'] 38 | if (requestSecret !== secret) { 39 | return response.status(400).send('Missing/invaild secret') 40 | } 41 | if (challenge) { 42 | return response.status(200).send(challenge) 43 | } 44 | return response.status(400).send() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/src/server.ts: -------------------------------------------------------------------------------- 1 | import { WebhookClient, MessageEmbed } from 'discord.js' 2 | import express from 'express' 3 | import { config } from './config' 4 | import { chunk, NETWORK_COLOR, websubRequest, wait } from './util' 5 | import { createIntentHandler, createWebsubMiddleware } from './middleware' 6 | import { networks } from '@unlock-protocol/networks' 7 | 8 | const port = process.env.PORT || 4000 9 | 10 | const keysCallbackEndpoint = new URL( 11 | '/callback/keys', 12 | config.baseURL 13 | ).toString() 14 | 15 | const locksCallbackEndpoint = new URL( 16 | '/callback/locks', 17 | config.baseURL 18 | ).toString() 19 | 20 | const webhookClient = new WebhookClient({ 21 | id: config.id, 22 | token: config.token, 23 | }) 24 | 25 | const websubMiddleware = createWebsubMiddleware({ 26 | secret: config.websubSecret, 27 | }) 28 | 29 | const intentHandler = createIntentHandler({ 30 | secret: config.websubSecret, 31 | }) 32 | 33 | const app = express() 34 | app.use(express.json()) 35 | 36 | app.get('/callback/locks', intentHandler) 37 | app.get('/callback/keys', intentHandler) 38 | 39 | app.post('/callback/locks', websubMiddleware, async (req) => { 40 | const embeds: MessageEmbed[] = [] 41 | const locks: any[] = req.body?.data 42 | const network = networks[req.body?.network] 43 | const lockIds = locks.map((lock: any) => lock.id) 44 | 45 | console.info(`New Locks: ${lockIds.join(', ')}`) 46 | 47 | if (!locks.length) { 48 | return 49 | } 50 | 51 | for (const lock of locks) { 52 | const embed = new MessageEmbed() 53 | if (network) { 54 | embed.addField('network', network.name) 55 | 56 | const explorerURL = network.explorer.urls.address(lock.address) 57 | if (explorerURL) { 58 | embed.setURL(explorerURL) 59 | } 60 | const networkColor = NETWORK_COLOR[network.id] 61 | if (networkColor) { 62 | embed.setColor(networkColor) 63 | } 64 | } 65 | 66 | embed.setTitle(`New Lock (${lock.id})`) 67 | embeds.push(embed) 68 | } 69 | const embedChunks = chunk(embeds, 3) 70 | 71 | for (const embedChunk of embedChunks) { 72 | await webhookClient.send({ 73 | embeds: embedChunk, 74 | }) 75 | } 76 | }) 77 | 78 | app.post('/callback/keys', websubMiddleware, async (req) => { 79 | const embeds: MessageEmbed[] = [] 80 | const keys: any[] = req.body?.data 81 | const network = networks[req.body?.network] 82 | const keyIds = keys.map((key: any) => key.id) 83 | console.info(`New Keys: ${keyIds.join(', ')}`) 84 | if (!keys.length) { 85 | return 86 | } 87 | 88 | for (const key of keys) { 89 | const embed = new MessageEmbed() 90 | if (network) { 91 | embed.addField('network', network.name) 92 | 93 | const explorerURL = network.explorer.urls.address(key.lock.address) 94 | if (explorerURL) { 95 | embed.setURL(explorerURL) 96 | } 97 | 98 | const networkColor = NETWORK_COLOR[network.id] 99 | if (networkColor) { 100 | embed.setColor(networkColor) 101 | } 102 | } 103 | 104 | embed.setTitle(`New key (${key.id})`) 105 | embed.addField('lock', key.lock.address) 106 | embeds.push(embed) 107 | } 108 | const embedChunks = chunk(embeds, 3) 109 | for (const embedChunk of embedChunks) { 110 | await webhookClient.send({ 111 | embeds: embedChunk, 112 | }) 113 | } 114 | }) 115 | 116 | async function subscribeHooks() { 117 | const subscribe = Object.values(networks).map(async (network: any) => { 118 | try { 119 | const locksEndpoint = new URL( 120 | `/api/hooks/${network.id}/locks`, 121 | config.locksmithURL 122 | ).toString() 123 | 124 | const keysEndpoint = new URL( 125 | `/api/hooks/${network.id}/keys`, 126 | config.locksmithURL 127 | ).toString() 128 | 129 | await websubRequest({ 130 | hubEndpoint: locksEndpoint, 131 | callbackEndpoint: locksCallbackEndpoint, 132 | leaseSeconds: config.leaseSeconds, 133 | topic: locksEndpoint, 134 | mode: 'subscribe', 135 | secret: config.websubSecret, 136 | }) 137 | 138 | await websubRequest({ 139 | hubEndpoint: keysEndpoint, 140 | callbackEndpoint: keysCallbackEndpoint, 141 | leaseSeconds: config.leaseSeconds, 142 | topic: keysEndpoint, 143 | mode: 'subscribe', 144 | secret: config.websubSecret, 145 | }) 146 | } catch (error) { 147 | console.error(error.message) 148 | } 149 | }) 150 | 151 | await Promise.all(subscribe) 152 | } 153 | 154 | async function unsubscribeHooks() { 155 | const unsubscribe = Object.values(networks).map(async (network: any) => { 156 | try { 157 | const locksEndpoint = new URL( 158 | `/api/hooks/${network.id}/locks`, 159 | config.locksmithURL 160 | ).toString() 161 | 162 | const keysEndpoint = new URL( 163 | `/api/hooks/${network.id}/keys`, 164 | config.locksmithURL 165 | ).toString() 166 | 167 | await websubRequest({ 168 | hubEndpoint: locksEndpoint, 169 | callbackEndpoint: locksCallbackEndpoint, 170 | topic: locksEndpoint, 171 | mode: 'unsubscribe', 172 | secret: config.websubSecret, 173 | }) 174 | 175 | await websubRequest({ 176 | hubEndpoint: keysEndpoint, 177 | callbackEndpoint: keysCallbackEndpoint, 178 | topic: keysEndpoint, 179 | mode: 'unsubscribe', 180 | secret: config.websubSecret, 181 | }) 182 | } catch (error) { 183 | console.error(error.message) 184 | } 185 | }) 186 | 187 | await Promise.all(unsubscribe) 188 | } 189 | 190 | async function shutdown() { 191 | console.info(`Shutting down the websub-discord bot`) 192 | await unsubscribeHooks() 193 | await wait(10000) // wait for 10 seconds to receive intent confirmation 194 | console.info('Unsubscribed to specified hooks') 195 | process.exit(0) 196 | } 197 | 198 | async function start() { 199 | console.log(`Listening for websub requests on port: ${port}`) 200 | // Renew subscription 201 | setInterval(() => subscribeHooks(), config.leaseSeconds) 202 | await subscribeHooks() 203 | console.info(`Subscribed to specified hooks`) 204 | } 205 | 206 | const server = app.listen(port) 207 | 208 | server.on('listening', start) 209 | server.on('close', shutdown) 210 | process.on('SIGINT', shutdown) 211 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/src/util.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { ColorResolvable } from 'discord.js' 3 | import fetch from 'cross-fetch' 4 | import { networks } from '@unlock-protocol/networks' 5 | export function chunk(array: readonly T[], size: number) { 6 | if (!array.length) { 7 | return [] 8 | } 9 | 10 | const result: T[][] = [] 11 | 12 | let currentIndex = 0 13 | 14 | while (currentIndex < array.length) { 15 | result.push(array.slice(currentIndex, currentIndex + size)) 16 | currentIndex += size 17 | } 18 | return result 19 | } 20 | 21 | interface CreateSignatureOptions { 22 | content: string 23 | secret: string 24 | algorithm: string 25 | } 26 | export function createSignature({ 27 | secret, 28 | content, 29 | algorithm, 30 | }: CreateSignatureOptions) { 31 | const signature = crypto 32 | .createHmac(algorithm, secret) 33 | .update(content) 34 | .digest('hex') 35 | return signature 36 | } 37 | 38 | interface SubscriptionOptions { 39 | callbackEndpoint: string 40 | hubEndpoint: string 41 | topic: string 42 | mode: 'subscribe' | 'unsubscribe' 43 | leaseSeconds?: number 44 | secret?: string 45 | } 46 | 47 | export async function websubRequest({ 48 | hubEndpoint, 49 | callbackEndpoint, 50 | mode, 51 | leaseSeconds = 86000, 52 | secret, 53 | }: SubscriptionOptions) { 54 | const formData = new URLSearchParams() 55 | formData.set('hub.topic', hubEndpoint) 56 | formData.set('hub.callback', callbackEndpoint) 57 | formData.set('hub.mode', mode) 58 | formData.set('hub.lease_seconds', leaseSeconds.toString(10)) 59 | if (secret) { 60 | formData.set('hub.secret', secret) 61 | } 62 | const result = await fetch(hubEndpoint, { 63 | method: 'POST', 64 | body: formData, 65 | headers: { 66 | 'Content-Type': 'application/x-www-form-urlencoded', 67 | }, 68 | }) 69 | 70 | if (!result.ok) { 71 | throw new Error(`failed to subscribe: ${result.statusText}`) 72 | } 73 | const text = await result.text() 74 | return text 75 | } 76 | 77 | export const wait = (ms: number) => 78 | new Promise((resolve) => setTimeout(resolve, ms)) 79 | 80 | export const NETWORK_COLOR: Record = { 81 | '1': '#3c3c3d', 82 | '10': '#ff001b', 83 | '100': '#39a7a1', 84 | '137': '#8146d9', 85 | '56': '#f8ba33', 86 | } 87 | -------------------------------------------------------------------------------- /examples/apps/backend/discord-webhook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "files": true 4 | }, 5 | "compilerOptions": { 6 | "outDir": "build", 7 | "rootDir": "src", 8 | "esModuleInterop": true, 9 | "useUnknownInCatchVariables": false, 10 | "skipLibCheck": true 11 | }, 12 | "exclude": [ 13 | "build" 14 | ] 15 | } -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/.env.template: -------------------------------------------------------------------------------- 1 | SECRET_COOKIE_PASSWORD=hasjnfiueabf987easf789asdsadasdasd3q3q 2 | NEXT_PUBLIC_BASE_URL=https://example.com 3 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/README.md: -------------------------------------------------------------------------------- 1 | # Unlock with Next 2 | 3 | This is an example using Unlock to tokengate application using NFTs both server and client side. 4 | 5 | ## Developing 6 | 7 | ### Configuration 8 | 9 | To get started, you need to fill in the configuration files inside `config/` folder. 10 | 11 | 1. `unlock.ts` holds configuration for our checkout and networks. 12 | 13 | Check out the schema and tutorial in our docs under ["Tools" > "Checkout" > "Configuration"](https://docs.unlock-protocol.com/tools/checkout/configuration) 14 | 15 | We use `@unlock-protocol/networks` package which provides network config and RPCs operated by unlock labs. See available networks and associated 16 | contract addresses in our docs under ["Core Protocol" > "Unlock" > "Networks"](https://docs.unlock-protocol.com/unlock/developers/smart-contracts#production-networks). 17 | 18 | It is recommended to use your own network config. You can pass that in the unlockjs to avoid depending on unlock labs. 19 | 20 | 2. `session.ts` holds configuration for how session will be created and managed. 21 | 22 | 3. Set the following environment variables. You can put them all inside `.env.local` file in development. Next.js will load them automatically. 23 | 24 | - `NEXT_PUBLIC_BASE_URL` - the base url of your site. For example, if your site is deployed to https://example.com/my-site, then the base url is `https://example.com`. If you are in development mode, it is set to http://localhost:3000 by default. 25 | 26 | - `SECRET_COOKIE_PASSWORD` - the secret password used to sign & encrypt cookies. 27 | 28 | ### Client Side Locking 29 | 30 | You can lock content on the client side by using the `useUser` hook. 31 | 32 | ```tsx 33 | import { NextPage } from 'next' 34 | import { useUser } from '~/hooks/useUser' 35 | 36 | const Page: NextPage = () => { 37 | const { user } = useUser() 38 | 39 | return ( 40 |
41 | {user?.isLoggedIn ? `Hello, ${user.walletAddress}` : `You need to login`} 42 |
43 | ) 44 | } 45 | 46 | export default Page 47 | ``` 48 | 49 | ### Server Side Locking 50 | 51 | #### Lock a page 52 | 53 | To lock a page generated server side, you can use the `withIronSessionSsr` middleware in the page file to pass different server side props based on whether the user is logged in or not. 54 | 55 | ```tsx 56 | import { withIronSessionSsr } from "iron-session/next"; 57 | import { NextPage } from "next"; 58 | 59 | interface Props { 60 | data: { 61 | values: number[], 62 | } | null; 63 | } 64 | 65 | const Page: NextPage = ({ data }) => { 66 | if (!data) { 67 | return
You don't have access
; 68 | } 69 | return ( 70 |
71 | data.values.map((value) =>
{value}
) 72 |
73 | ); 74 | }; 75 | 76 | export default Page 77 | 78 | export const getServerSideProps = 79 | withIronSessionSsr < 80 | Props > 81 | ((ctx) => { 82 | const user = ctx.req.session.user; 83 | return user?.isLoggedIn 84 | ? { 85 | props: { 86 | data: { 87 | values: [1, 2, 3], 88 | }, 89 | }, 90 | } 91 | : { 92 | props: { 93 | data: null, 94 | }, 95 | }; 96 | }, 97 | sessionOptions); 98 | ``` 99 | 100 | #### Lock an API endpoint 101 | 102 | To lock an API endpoint server side, you can use the `withIronSessionApiRoute` middleware in the API file to return different responses based on whether the user is logged in or not. 103 | 104 | ```typescript 105 | import { withIronSessionApiRoute } from 'iron-session/next' 106 | import { NextApiRequest, NextApiResponse } from 'next' 107 | import { sessionOptions } from '~/config/session' 108 | 109 | export default withIronSessionApiRoute(userRoute, sessionOptions) 110 | 111 | async function userRoute( 112 | req: NextApiRequest, 113 | res: NextApiResponse<{ locked: boolean }> 114 | ) { 115 | if (req.session.user?.isLoggedIn) { 116 | res.json({ 117 | locked: false, 118 | }) 119 | } else { 120 | res.json({ 121 | locked: true, 122 | }) 123 | } 124 | } 125 | ``` 126 | 127 | ## Getting Started 128 | 129 | First, run the development server: 130 | 131 | ```bash 132 | npm run dev 133 | # or 134 | yarn dev 135 | ``` 136 | 137 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 138 | 139 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 140 | 141 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/user](http://localhost:3000/api/user). This endpoint can be edited in `pages/api/user.ts`. 142 | 143 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 144 | 145 | ## Learn More 146 | 147 | To learn more about Next.js, take a look at the following resources: 148 | 149 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 150 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 151 | 152 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 153 | 154 | ## Deploy on Vercel 155 | 156 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 157 | 158 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 159 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: ["locksmith.unlock-protocol.com"], 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gating", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@unlock-protocol/networks": "^0.0.8", 13 | "@unlock-protocol/unlock-js": "^0.30.5", 14 | "ethers": "^5.6.1", 15 | "iron-session": "^6.1.0", 16 | "next": "^13.0.0", 17 | "react": "18.2.0", 18 | "react-dom": "18.2.0", 19 | "swr": "^1.2.2" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "18.11.5", 23 | "@types/react": "18.0.23", 24 | "autoprefixer": "^10.4.4", 25 | "eslint": "8.26.0", 26 | "eslint-config-next": "13.0.0", 27 | "postcss": "^8.4.12", 28 | "tailwindcss": "^3.0.23", 29 | "typescript": "4.8.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unlock-protocol/examples/d5e95417ad35f75466b3c6cf16e71dbc2fe495b8/examples/apps/nextjs/gating/public/favicon.ico -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/config/session.ts: -------------------------------------------------------------------------------- 1 | import type { IronSessionOptions } from "iron-session"; 2 | 3 | export const sessionOptions: IronSessionOptions = { 4 | // You can rotate password here for improved security using an object with index as keys 5 | password: process.env.SECRET_COOKIE_PASSWORD?.toString()!, 6 | cookieName: "unlock-next", 7 | cookieOptions: { 8 | secure: process.env.NODE_ENV === "production", 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/config/site.ts: -------------------------------------------------------------------------------- 1 | export const baseURL = 2 | process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; 3 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/config/unlock.ts: -------------------------------------------------------------------------------- 1 | export const paywallConfig: Record = { 2 | locks: { 3 | '0xb77030a7e47a5eb942a4748000125e70be598632': { 4 | network: 137, 5 | name: 'Unlock Community', 6 | }, 7 | }, 8 | pessimistic: false, 9 | title: 'Super Hero Zone', 10 | icon: 'https://pin.ski/3Fc5Dqa', 11 | persistentCheckout: false, 12 | } 13 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import Router from "next/router"; 3 | import useSWR from "swr"; 4 | import { User } from "~/types"; 5 | import { fetchJson } from "~/utils"; 6 | 7 | export function useUser({ redirectTo = "", redirectIfFound = false } = {}) { 8 | const { data: user, mutate: mutateUser } = useSWR("/api/user"); 9 | 10 | async function logoutUser() { 11 | const user: User = await fetchJson("/api/logout", { 12 | method: "POST", 13 | }); 14 | mutateUser(user); 15 | } 16 | 17 | useEffect(() => { 18 | // if no redirect needed, just return (example: already on /dashboard) 19 | // if user data not yet there (fetch in progress, logged in or not) then don't do anything yet 20 | if (!redirectTo || !user) return; 21 | 22 | if ( 23 | // If redirectTo is set, redirect if the user was not found. 24 | (redirectTo && !redirectIfFound && !user?.isLoggedIn) || 25 | // If redirectIfFound is also set, redirect if the user was found 26 | (redirectIfFound && user?.isLoggedIn) 27 | ) { 28 | Router.push(redirectTo); 29 | } 30 | }, [user, redirectIfFound, redirectTo]); 31 | 32 | return { user, mutateUser, logoutUser }; 33 | } 34 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { fetchJson } from "~/utils"; 4 | import { SWRConfig } from "swr"; 5 | 6 | function CustomApp({ Component, pageProps }: AppProps) { 7 | return ( 8 | { 12 | console.error(err); 13 | }, 14 | }} 15 | > 16 | 17 | 18 | ); 19 | } 20 | 21 | export default CustomApp; 22 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/api/login.ts: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { Membership, User } from "~/types"; 4 | import { getValidMemberships, hasMembership } from "~/utils"; 5 | import { paywallConfig } from "~/config/unlock"; 6 | import { ethers } from "ethers"; 7 | import { baseURL } from "~/config/site"; 8 | import crypto from "crypto"; 9 | import { sessionOptions } from "~/config/session"; 10 | 11 | export default withIronSessionApiRoute(loginRoute, sessionOptions); 12 | 13 | async function loginRoute(request: NextApiRequest, response: NextApiResponse) { 14 | try { 15 | const signature = request.query.signature as string; 16 | const digest = request.query.digest as string; 17 | 18 | if (!signature) { 19 | const messageToSign = `I authorize to login: ${crypto 20 | .randomBytes(32) 21 | .toString("hex")}`; 22 | 23 | return redirectToPurchase(messageToSign, request, response); 24 | } else { 25 | const address = ethers.utils.verifyMessage(digest, signature); 26 | const memberships = await getValidMemberships(address); 27 | const hasAccess = !!memberships.length; 28 | 29 | if (!hasAccess) { 30 | return response 31 | .status(401) 32 | .send( 33 | "You do not have a valid membership. You can purchase one by reloading this page and checking out a membership this time." 34 | ); 35 | } 36 | 37 | const user: User = { 38 | walletAddress: address, 39 | isLoggedIn: true, 40 | digest, 41 | signature, 42 | memberships, 43 | }; 44 | 45 | request.session.user = user; 46 | await request.session.save(); 47 | response.redirect(baseURL); 48 | } 49 | } catch (error) { 50 | response.status(500).json({ message: (error as Error).message }); 51 | } 52 | } 53 | 54 | function redirectToPurchase( 55 | digest: string, 56 | request: NextApiRequest, 57 | response: NextApiResponse 58 | ) { 59 | const redirectBack = new URL(request.url!, baseURL); 60 | redirectBack.searchParams.append("digest", digest); 61 | const redirectUrl = new URL("https://app.unlock-protocol.com/checkout"); 62 | paywallConfig.messageToSign = digest; 63 | redirectUrl.searchParams.append( 64 | "paywallConfig", 65 | JSON.stringify(paywallConfig) 66 | ); 67 | redirectUrl.searchParams.append("redirectUri", redirectBack.toString()); 68 | response.redirect(redirectUrl.toString()); 69 | } 70 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/api/logout.ts: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import type { User } from "~/types"; 4 | import { sessionOptions } from "~/config/session"; 5 | 6 | export default withIronSessionApiRoute(logoutRoute, sessionOptions); 7 | 8 | function logoutRoute(req: NextApiRequest, res: NextApiResponse) { 9 | req.session.destroy(); 10 | res.json({ 11 | isLoggedIn: false, 12 | walletAddress: "", 13 | digest: "", 14 | signature: "", 15 | memberships: [], 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/api/memberships.ts: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { MembershipMetadata } from "~/types"; 4 | import { sessionOptions } from "~/config/session"; 5 | import { fetchJson } from "~/utils"; 6 | 7 | export default withIronSessionApiRoute(userRoute, sessionOptions); 8 | 9 | async function userRoute( 10 | req: NextApiRequest, 11 | res: NextApiResponse<{ 12 | memberships: MembershipMetadata[]; 13 | }> 14 | ) { 15 | if (req.session.user) { 16 | const memberships = await Promise.all( 17 | req.session.user.memberships!.map( 18 | async ({ network, id, lockAddress }) => { 19 | const metadata: Object = await fetchJson( 20 | `https://locksmith.unlock-protocol.com/api/key/${network}/${lockAddress}/${id}` 21 | ); 22 | 23 | return { 24 | id, 25 | network, 26 | lockAddress, 27 | ...metadata, 28 | } as MembershipMetadata; 29 | } 30 | ) 31 | ); 32 | res.json({ 33 | memberships, 34 | }); 35 | } else { 36 | res.json({ 37 | memberships: [], 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/api/user.ts: -------------------------------------------------------------------------------- 1 | import { withIronSessionApiRoute } from "iron-session/next"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { User } from "~/types"; 4 | import { sessionOptions } from "~/config/session"; 5 | 6 | export default withIronSessionApiRoute(userRoute, sessionOptions); 7 | 8 | async function userRoute(req: NextApiRequest, res: NextApiResponse) { 9 | if (req.session.user) { 10 | res.json({ 11 | ...req.session.user, 12 | isLoggedIn: true, 13 | }); 14 | } else { 15 | res.json({ 16 | isLoggedIn: false, 17 | walletAddress: "", 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Link from "next/link"; 3 | import useSWR from "swr"; 4 | import { useUser } from "~/hooks/useUser"; 5 | import { MembershipMetadata } from "~/types"; 6 | 7 | const Home: NextPage = () => { 8 | const { logoutUser, user } = useUser(); 9 | const { data } = 10 | useSWR<{ memberships: MembershipMetadata[] }>("/api/memberships"); 11 | 12 | const buttonClass = 13 | "bg-[#603DEB] text-white px-8 text-lg py-2 font-bold rounded hover:opacity-80"; 14 | 15 | if (!user?.isLoggedIn) { 16 | return ( 17 |
18 |
19 |

Login using NFT membership

20 |

21 | You do not have valid NFT membership to access this page. Verify or 22 | buy membership by using the login button below. 23 |

24 |
25 |
26 |
27 | 30 |
31 |
32 |
33 | ); 34 | } 35 | return ( 36 |
37 |
38 |

Your Unlock Memberships (NFTs)

39 |

40 | These are all the available memberships valid to access this 41 | application. 42 |

43 |
44 | 47 |
48 |
49 |
50 | {data?.memberships.map((membership) => { 51 | const expiration = new Date(membership.expiration * 1000); 52 | return ( 53 |
54 |
55 | {membership.name} 60 |
61 |
62 |

{membership.name}

63 |

{membership.description}

64 |

65 | Valid until{" "} 66 | 69 |

70 |
71 |
72 | ); 73 | })} 74 |
75 |
76 | ); 77 | }; 78 | 79 | export default Home; 80 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/pages/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@unlock-protocol/unlock-express"; 2 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply antialiased bg-slate-50; 7 | } 8 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | walletAddress: string; 3 | digest?: string; 4 | signature?: string; 5 | isLoggedIn: boolean; 6 | memberships?: Membership[]; 7 | } 8 | 9 | export interface Attribute { 10 | trait_type: string; 11 | value: string | number; 12 | display_type: string; 13 | } 14 | export interface Membership { 15 | id: number; 16 | network: number; 17 | lockAddress: string; 18 | } 19 | 20 | export interface MembershipMetadata { 21 | id: number; 22 | name: string; 23 | description: string; 24 | image: string; 25 | network: number; 26 | owner: string; 27 | lockAddress: string; 28 | attributes: Attribute[]; 29 | expiration: number; 30 | } 31 | 32 | declare module "iron-session" { 33 | interface IronSessionData { 34 | user?: User; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { paywallConfig } from "~/config/unlock"; 2 | import { networks } from "@unlock-protocol/networks"; 3 | import { Web3Service } from "@unlock-protocol/unlock-js"; 4 | import { Membership } from "./types"; 5 | interface GetHasValidKeyOptions { 6 | network: number; 7 | lockAddress: string; 8 | userAddress: string; 9 | } 10 | 11 | export async function getValidKey({ 12 | network, 13 | lockAddress, 14 | userAddress, 15 | }: GetHasValidKeyOptions) { 16 | const unlockWeb3Service = new Web3Service(networks); 17 | const key = await unlockWeb3Service.getKeyByLockForOwner( 18 | lockAddress, 19 | userAddress, 20 | network 21 | ); 22 | 23 | const keyId = key.tokenId; 24 | 25 | if (keyId <= 0) { 26 | return; 27 | } 28 | 29 | return { 30 | id: keyId, 31 | lockAddress, 32 | network, 33 | } as Membership; 34 | } 35 | 36 | export async function getValidMemberships(userAddress: string) { 37 | const promises = Object.keys(paywallConfig.locks as any).map( 38 | (lockAddress) => { 39 | return getValidKey({ 40 | lockAddress, 41 | userAddress, 42 | network: (paywallConfig.locks as any)[lockAddress].network, 43 | }); 44 | } 45 | ); 46 | const results = await Promise.all(promises); 47 | return results as Membership[]; 48 | } 49 | 50 | export async function hasMembership(userAddress: string) { 51 | const results = await getValidMemberships(userAddress); 52 | return !!results.length; 53 | } 54 | 55 | export async function fetchJson( 56 | input: RequestInfo, 57 | init?: RequestInit 58 | ): Promise { 59 | const response = await fetch(input, init); 60 | const data = await response.json(); 61 | if (response.ok) { 62 | return data; 63 | } 64 | throw new FetchError({ 65 | message: response.statusText, 66 | response, 67 | data, 68 | }); 69 | } 70 | 71 | export class FetchError extends Error { 72 | response: Response; 73 | data: { 74 | message: string; 75 | }; 76 | constructor({ 77 | message, 78 | response, 79 | data, 80 | }: { 81 | message: string; 82 | response: Response; 83 | data: { 84 | message: string; 85 | }; 86 | }) { 87 | super(message); 88 | if (Error.captureStackTrace) { 89 | Error.captureStackTrace(this, FetchError); 90 | } 91 | this.name = "FetchError"; 92 | this.response = response; 93 | this.data = data ?? { message: message }; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /examples/apps/nextjs/gating/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "~/*": [ 24 | "./src/*" 25 | ] 26 | } 27 | }, 28 | "include": [ 29 | "next-env.d.ts", 30 | "**/*.ts", 31 | "**/*.tsx" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/README.md: -------------------------------------------------------------------------------- 1 | # Ticket Chat 2 | 3 | A realtime token gated chat application which can use unlock based QR tickets to let users login. 4 | This example uses [liveblocks](https://liveblocks.io/) for storing realtime chat data. You will need to create an account and get an API key to run this exaple. 5 | 6 | ## Developing 7 | 8 | 1. Create a .env.local file with the following variables. 9 | 10 | ```shell 11 | VITE_LIVEBLOCK_PUBLIC_KEY=key # Key from liveblocks project. 12 | VITE_BASE_URL=http://localhost:3000 13 | ``` 14 | 15 | 1. Run `yarn install` 16 | 17 | 1. Run `yarn dev` to start the application. 18 | 19 | For production, you can build the app or directly deploy to a hosting provider such as vercel. 20 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 17 | 18 | Ticket Chat - real time chat between NFT holders 19 | 23 | 27 | 28 | 29 | 30 | 31 | 35 | 39 | 40 | 41 | 42 | 43 | 44 | 48 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/" 4 | status = 200 -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ticket-chat", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.6.6", 13 | "@liveblocks/client": "^0.17.6", 14 | "@liveblocks/react": "^0.17.6", 15 | "@tanstack/react-query": "^4.0.3", 16 | "blockies-ts": "^1.0.0", 17 | "dayjs": "^1.11.3", 18 | "ethers": "^5.6.9", 19 | "qr-scanner": "^1.4.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-dropzone": "^14.2.2", 23 | "react-hot-toast": "^2.3.0", 24 | "react-icons": "^4.4.0", 25 | "react-router-dom": "6", 26 | "use-local-storage-state": "^18.1.0", 27 | "wouter": "^2.8.0-alpha.2", 28 | "zod": "^3.17.9" 29 | }, 30 | "devDependencies": { 31 | "@tailwindcss/forms": "^0.5.2", 32 | "@types/react": "^18.0.15", 33 | "@types/react-dom": "^18.0.6", 34 | "@vitejs/plugin-react": "^2.0.0", 35 | "autoprefixer": "^10.4.7", 36 | "postcss": "^8.4.14", 37 | "tailwindcss": "^3.1.6", 38 | "typescript": "^4.6.4", 39 | "vite": "^3.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/public/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unlock-protocol/examples/d5e95417ad35f75466b3c6cf16e71dbc2fe495b8/examples/apps/react/ticket-chat/public/home.png -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from "./pages/Room"; 2 | import { Home } from "./pages/Home"; 3 | import { Toaster } from "react-hot-toast"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 6 | 7 | const queryClient = new QueryClient(); 8 | 9 | function App() { 10 | return ( 11 | 12 | 13 | 14 | } /> 15 | } /> 16 | 404 not found. 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/components/BlockAvatar.tsx: -------------------------------------------------------------------------------- 1 | import * as blockies from "blockies-ts"; 2 | 3 | interface Props { 4 | seed: string; 5 | className?: string; 6 | } 7 | 8 | export function BlockAvatar({ className, seed }: Props) { 9 | const imgSrc = blockies 10 | .create({ 11 | seed, 12 | }) 13 | .toDataURL(); 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Link, useSearchParams } from "react-router-dom"; 3 | import { useUser } from "../hooks/useUser"; 4 | import { ethers } from "ethers"; 5 | import toast from "react-hot-toast"; 6 | import { loginURL } from "../utils/unlock"; 7 | import { IoTicketOutline as TicketIcon } from "react-icons/io5"; 8 | 9 | export function Navigation() { 10 | const { user, signOut, signIn } = useUser(); 11 | const [params] = useSearchParams(); 12 | 13 | useEffect(() => { 14 | const error = params.get("error"); 15 | const code = params.get("code"); 16 | if (error) { 17 | toast.error(error); 18 | } else if (code) { 19 | const decoded = atob(code); 20 | const json = JSON.parse(decoded); 21 | const walletAddress = ethers.utils.verifyMessage(json.d, json.s); 22 | signIn({ 23 | address: walletAddress, 24 | }); 25 | } 26 | }, [params]); 27 | 28 | const login = loginURL(); 29 | 30 | return ( 31 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/components/QRCodeDrop.tsx: -------------------------------------------------------------------------------- 1 | import { useDropzone } from "react-dropzone"; 2 | import QrScanner from "qr-scanner"; 3 | import toast from "react-hot-toast"; 4 | import { parseTicket } from "../utils/ticket"; 5 | import { useLocation } from "wouter"; 6 | import { useUser } from "../hooks/useUser"; 7 | import { useState } from "react"; 8 | import { CgSpinnerAlt as LoadingIcon } from "react-icons/cg"; 9 | import { useNavigate } from "react-router-dom"; 10 | 11 | export function QRCodeDrop() { 12 | const navigate = useNavigate(); 13 | const { signIn } = useUser(); 14 | const [isSigning, setIsSigning] = useState(false); 15 | const { getRootProps, getInputProps } = useDropzone({ 16 | accept: { 17 | "image/*": [".png", ".jpeg", ".gif", ".jpg"], 18 | }, 19 | onDrop: async ([file]) => { 20 | try { 21 | setIsSigning(true); 22 | const url = URL.createObjectURL(file); 23 | const result = await QrScanner.scanImage(url, {}); 24 | const ticketURL = new URL(result as unknown as string); 25 | const ticket = parseTicket({ 26 | data: ticketURL.searchParams.get("data"), 27 | sig: ticketURL.searchParams.get("sig"), 28 | }); 29 | if (!ticket) { 30 | throw new Error("Invalid Ticket"); 31 | } 32 | const user = { 33 | address: ticket.data.account, 34 | }; 35 | signIn(user); 36 | setIsSigning(false); 37 | navigate(`/rooms/${ticket.data.lockAddress}`); 38 | } catch (error) { 39 | setIsSigning(false); 40 | toast.error("Invalid Ticket"); 41 | } 42 | }, 43 | }); 44 | return ( 45 |
46 |
52 | 53 | {isSigning ? ( 54 | 55 | ) : ( 56 |

57 | Drop Unlock Ticket QR Code 58 |

59 | )} 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/config/base.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | baseURL: import.meta.env.VITE_BASE_URL, 3 | }; 4 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/config/liveblock.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@liveblocks/client"; 2 | import { createRoomContext } from "@liveblocks/react"; 3 | 4 | const LIVEBLOCK_PUBLIC_KEY = import.meta.env.VITE_LIVEBLOCK_PUBLIC_KEY!; 5 | 6 | const client = createClient({ 7 | publicApiKey: LIVEBLOCK_PUBLIC_KEY, 8 | }); 9 | 10 | export const { 11 | RoomProvider, 12 | useMyPresence, 13 | useOthers, 14 | useUpdateMyPresence, 15 | useList, 16 | } = createRoomContext(client); 17 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import useLocalStorage from "use-local-storage-state"; 2 | 3 | interface Ticket { 4 | id: string; 5 | lock: string; 6 | network: number; 7 | } 8 | 9 | interface User { 10 | address: string; 11 | } 12 | 13 | export function useUser() { 14 | const [user, setUser] = useLocalStorage("account", { 15 | defaultValue: null, 16 | }); 17 | 18 | const signOut = () => { 19 | setUser(null); 20 | }; 21 | 22 | const signIn = (user: User) => { 23 | setUser(user); 24 | }; 25 | 26 | return { 27 | user, 28 | signIn, 29 | signOut, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply antialiased bg-[#FFFBF4]; 7 | } 8 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { IoTicketOutline as TicketIcon } from "react-icons/io5"; 3 | import { useNavigate, useSearchParams } from "react-router-dom"; 4 | import { QRCodeDrop } from "../components/QRCodeDrop"; 5 | import { useUser } from "../hooks/useUser"; 6 | import { getAllMemberships, getLock, loginURL } from "../utils/unlock"; 7 | import { Navigation } from "../components/Navigation"; 8 | 9 | export function Home() { 10 | const { user } = useUser(); 11 | const client = useQueryClient(); 12 | const navigate = useNavigate(); 13 | 14 | const { isLoading: isMembershipsLoading, data: memberships } = useQuery( 15 | [user], 16 | async () => { 17 | if (user) { 18 | const memberships = await getAllMemberships(user.address); 19 | const result = await Promise.all( 20 | memberships.map(async (item) => { 21 | const lock = await client.fetchQuery( 22 | [item.lock, item.network], 23 | () => { 24 | return getLock(item.lock, item.network); 25 | } 26 | ); 27 | return { 28 | ...item, 29 | lockName: lock.name, 30 | }; 31 | }) 32 | ); 33 | return result; 34 | } 35 | }, 36 | { 37 | enabled: !!user, 38 | } 39 | ); 40 | 41 | const login = loginURL(); 42 | 43 | return ( 44 |
45 | 46 |
47 |
48 |
49 | 50 |

Ticket Chat

51 |
52 |

53 | Drop your Unlock ticket QR code and chat with others on the same 54 | event. 55 |

56 |
57 |
58 |
59 | 60 | {user && !isMembershipsLoading ? ( 61 |
62 |

Your membership chatrooms

63 |
64 | {memberships && 65 | memberships.map((item) => ( 66 | 79 | ))} 80 |
81 |
82 | ) : ( 83 |
84 |
OR
85 | 94 |
95 | )} 96 |
97 |
98 | 108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/pages/Room.tsx: -------------------------------------------------------------------------------- 1 | import { LiveList } from "@liveblocks/client"; 2 | import { useCallback, useEffect, useRef, useState } from "react"; 3 | import { BlockAvatar } from "../components/BlockAvatar"; 4 | import { 5 | RoomProvider, 6 | useList, 7 | useOthers, 8 | useUpdateMyPresence, 9 | } from "../config/liveblock"; 10 | import { useUser } from "../hooks/useUser"; 11 | import { CgSpinner as LoadingIcon } from "react-icons/cg"; 12 | import dayjs from "dayjs"; 13 | import relativeTimePlugin from "dayjs/plugin/relativeTime"; 14 | import { useParams } from "react-router-dom"; 15 | import { Navigation } from "../components/Navigation"; 16 | import { useQuery } from "@tanstack/react-query"; 17 | import { getAllMemberships } from "../utils/unlock"; 18 | 19 | dayjs.extend(relativeTimePlugin); 20 | 21 | export function SomeoneIsTyping() { 22 | const typers = useOthers() 23 | .toArray() 24 | .filter((user) => user.presence?.isTyping); 25 | 26 | return ( 27 |
28 | {!!typers.length && ( 29 |

{typers.length > 1 ? "People are" : "Someone is"} typing...

30 | )} 31 |
32 | ); 33 | } 34 | 35 | export function HowManyAreHere() { 36 | const others = useOthers(); 37 | return
{others.count} members
; 38 | } 39 | 40 | interface ChatRoomProps { 41 | address: string; 42 | } 43 | 44 | export function ChatRoom({ address }: ChatRoomProps) { 45 | const updatePresence = useUpdateMyPresence(); 46 | const [message, setMessage] = useState(""); 47 | const { user } = useUser(); 48 | const messages = useList("messages") as any[] | null; 49 | const containerRef = useRef(null); 50 | const scrollDown = useCallback(() => { 51 | if (containerRef.current) { 52 | containerRef.current.scrollTop = containerRef.current.scrollHeight; 53 | } 54 | }, [containerRef]); 55 | 56 | useEffect(() => { 57 | scrollDown(); 58 | }, [scrollDown, messages?.length]); 59 | 60 | const walletAddress = user?.address; 61 | const { isLoading: isLocksLoading, data: locks } = useQuery( 62 | [walletAddress], 63 | async () => { 64 | if (user) { 65 | const memberships = await getAllMemberships(user.address); 66 | return memberships.map((item) => item.lock.toLowerCase()); 67 | } 68 | }, 69 | { 70 | enabled: !!walletAddress, 71 | } 72 | ); 73 | 74 | const isDisabled = !locks?.includes(address.toLowerCase()); 75 | 76 | if (messages === null) { 77 | return ( 78 |
79 |
80 | 81 |

Loading chat room

82 |
83 |
84 | ); 85 | } 86 | 87 | return ( 88 |
89 | 90 |
91 |
95 | {messages.map((message, index) => { 96 | const timeSince = dayjs().from(message.createdAt, true); 97 | return ( 98 |
102 | 103 |
104 |

{message.text}

105 | 108 |
109 |
110 | ); 111 | })} 112 |
113 |
114 |
115 | 116 | 117 |
118 | { 130 | setMessage(e.target.value); 131 | updatePresence({ isTyping: true }); 132 | }} 133 | onKeyDown={(e) => { 134 | if (e.key === "Enter") { 135 | updatePresence({ isTyping: false }); 136 | messages.push({ 137 | text: message, 138 | address: user?.address, 139 | createdAt: Date.now(), 140 | }); 141 | setMessage(""); 142 | } 143 | }} 144 | onBlur={() => updatePresence({ isTyping: false })} 145 | /> 146 |
147 |
148 |
149 | ); 150 | } 151 | 152 | export function Room() { 153 | const { lockAddress } = useParams<{ lockAddress: string }>(); 154 | const roomId = lockAddress!; 155 | return ( 156 | 162 | 163 | 164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/utils/ticket.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const Ticket = z.object({ 4 | account: z.string(), 5 | timestamp: z.number(), 6 | tokenId: z 7 | .union([z.string(), z.number()]) 8 | .transform((value) => value.toString()), 9 | network: z.number(), 10 | lockAddress: z.string(), 11 | }); 12 | 13 | const TicketObject = z.object({ 14 | data: Ticket, 15 | sig: z.string(), 16 | raw: z.string(), 17 | }); 18 | 19 | interface Options { 20 | data?: string | null; 21 | sig?: string | null; 22 | } 23 | 24 | export function parseTicket({ data, sig }: Options) { 25 | try { 26 | if (!(sig && data)) { 27 | return; 28 | } 29 | const raw = decodeURIComponent(data); 30 | const result = TicketObject.parse({ 31 | sig, 32 | raw, 33 | data: JSON.parse(raw), 34 | }); 35 | return result; 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/utils/unlock.ts: -------------------------------------------------------------------------------- 1 | import { networks } from "../config/networks"; 2 | 3 | export function loginURL() { 4 | const current = new URL(window.location.href); 5 | const endpoint = new URL(`https://app.unlock-protocol.com/alpha/checkout`); 6 | endpoint.searchParams.append("client_id", current.host); 7 | endpoint.searchParams.append("redirect_uri", window.location.href); 8 | return endpoint.toString(); 9 | } 10 | 11 | const LOCK = ` 12 | query getLock($lockAddress: String!) { 13 | locks(where: { address: $lockAddress }) { 14 | name 15 | } 16 | } 17 | `; 18 | 19 | const KEY_PURCHASES = ` 20 | query getMemberships($walletAddress: String!) { 21 | keyPurchases(where: { purchaser: $walletAddress }) { 22 | timestamp 23 | lock { 24 | address 25 | } 26 | id 27 | } 28 | } 29 | `; 30 | 31 | export async function getMembershipsBynetwork( 32 | walletAddress: string, 33 | networkId: number 34 | ) { 35 | const network = networks[networkId]; 36 | const response = await fetch(network.subgraphURI, { 37 | method: "POST", 38 | body: JSON.stringify({ 39 | query: KEY_PURCHASES, 40 | variables: { 41 | walletAddress, 42 | }, 43 | }), 44 | }); 45 | const json = await response.json(); 46 | const items = json.data.keyPurchases.map((item: any) => ({ 47 | ...item, 48 | network: network.id, 49 | })); 50 | return items; 51 | } 52 | 53 | export async function getAllMemberships(walletAddress: string) { 54 | const items = await Promise.all( 55 | Object.values(networks).map((network) => 56 | getMembershipsBynetwork(walletAddress, network.id) 57 | ) 58 | ); 59 | return items.flat(); 60 | } 61 | 62 | export async function getLock(lockAddress: string, networkId: number) { 63 | const network = networks[networkId]; 64 | const response = await fetch(network.subgraphURI, { 65 | method: "POST", 66 | body: JSON.stringify({ 67 | query: LOCK, 68 | variables: { 69 | lockAddress, 70 | }, 71 | }), 72 | }); 73 | const json = await response.json(); 74 | return json.data?.locks?.[0]; 75 | } 76 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | const forms = require("@tailwindcss/forms"); 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 7 | theme: { 8 | extend: { 9 | fontFamily: { 10 | sans: ["Inter", ...defaultTheme.fontFamily.sans], 11 | mono: ["Roboto Mono", ...defaultTheme.fontFamily.mono], 12 | }, 13 | colors: { 14 | brand: { 15 | dark: "#020207", 16 | gray: "#535353", 17 | primary: "#FFF7E8", 18 | secondary: "#FF6771", 19 | ui: { 20 | primary: "#603DEB", 21 | secondary: "#020207", 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | plugins: [forms], 28 | }; 29 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /examples/apps/react/ticket-chat/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/README.md: -------------------------------------------------------------------------------- 1 | # Wagmi 2 | 3 | A basic react application ([create-react-app](https://create-react-app.dev/)) that shows how to use [Wagmi.sh](https://wagmi.sh/) with Unlock: 4 | 5 | - deploying a lock (check [DeployLock.js](./src/DeployLock.js)) 6 | - purchase/mint NFT membership (check [PurchaseKey.js](./src/PurchaseKey.js)) 7 | 8 | You can run locally with `yarn && yarn start` and you can test it on [Vercel](https://examples-wagmi.vercel.app/). 9 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wagmi-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@tailwindcss/forms": "^0.5.3", 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@unlock-protocol/contracts": "^0.0.9", 11 | "connectkit": "^0.0.2", 12 | "ethers": "^5.7.1", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-scripts": "5.0.1", 16 | "use-debounce": "^8.0.4", 17 | "wagmi": "^0.6.6", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "autoprefixer": "^10.4.11", 40 | "postcss": "^8.4.16", 41 | "tailwindcss": "^3.1.8" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unlock-protocol/examples/d5e95417ad35f75466b3c6cf16e71dbc2fe495b8/examples/apps/react/wagmi.sh/public/favicon.ico -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/App.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { WagmiConfig, createClient, chain } from 'wagmi' 3 | 4 | import { useAccount } from 'wagmi' 5 | 6 | import { 7 | ConnectKitProvider, 8 | ConnectKitButton, 9 | getDefaultClient, 10 | } from 'connectkit' 11 | import './App.css' 12 | import DeployLock from './DeployLock' 13 | import PurchaseKey from './PurchaseKey' 14 | const alchemyId = process.env.ALCHEMY_ID 15 | 16 | const chains = [chain.goerli] 17 | 18 | const client = createClient( 19 | getDefaultClient({ 20 | appName: 'Your App Name', 21 | alchemyId, 22 | chains, 23 | }) 24 | ) 25 | 26 | const App = () => { 27 | return ( 28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 | ) 38 | } 39 | 40 | const Content = () => { 41 | const { isConnected } = useAccount() 42 | const [action, setAction] = useState('') 43 | 44 | if (!isConnected) { 45 | return 46 | } 47 | return ( 48 | <> 49 |
50 | 51 |
52 | 53 | {action === 'deploy' && } 54 | {action === 'purchase' && } 55 | 56 | {action === '' && ( 57 | <> 58 |

Using Unlock with Wagmi!

59 | 65 | 71 | 72 | )} 73 | {action !== '' && ( 74 | 80 | )} 81 | 82 | ) 83 | } 84 | 85 | export default App 86 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/DeployLock.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ethers } from 'ethers' 3 | import { UnlockV11, PublicLockV11 } from "@unlock-protocol/contracts"; 4 | 5 | import { 6 | erc20ABI, 7 | useAccount, 8 | useSendTransaction, 9 | useWaitForTransaction, 10 | useContractRead, 11 | usePrepareContractWrite 12 | } from 'wagmi' 13 | 14 | const lockInterface = new ethers.utils.Interface(PublicLockV11.abi) 15 | 16 | 17 | export function DeployLock() { 18 | const { address: creator } = useAccount() 19 | 20 | const [calldata, setCalldata] = React.useState('') 21 | const [name, setName] = React.useState('My Membership') 22 | const [price, setPrice] = React.useState(1) 23 | const [duration, setDuration] = React.useState(30) // in days 24 | const [supply, setSupply] = React.useState(10000) 25 | const [currency, setCurrency] = React.useState('') // address of the ERC20. If 0x0, uses base currency 26 | 27 | const { data: decimals } = useContractRead({ 28 | addressOrName: currency, 29 | contractInterface: erc20ABI, 30 | functionName: 'decimals', 31 | enabled: currency !== ethers.constants.AddressZero 32 | }) 33 | 34 | 35 | React.useEffect(() => { 36 | const prepareCalldata = async () => { 37 | setCalldata(lockInterface.encodeFunctionData( 38 | 'initialize(address,uint256,address,uint256,uint256,string)', 39 | [ 40 | creator, 41 | duration * 60 * 60 * 24, // duration is in days! 42 | currency || ethers.constants.AddressZero, 43 | ethers.utils.parseUnits(price.toString(), decimals || 18), 44 | supply, 45 | name, 46 | ] 47 | )) 48 | } 49 | prepareCalldata() 50 | }, [creator, duration, currency, price, supply, name, decimals]) 51 | 52 | 53 | const { config } = usePrepareContractWrite({ 54 | addressOrName: '0x627118a4fB747016911e5cDA82e2E77C531e8206', 55 | contractInterface: UnlockV11.abi, 56 | functionName: 'createUpgradeableLockAtVersion', 57 | args: [calldata, 11] // We currently deploy version 11 58 | }) 59 | const { data: transaction, sendTransaction } = useSendTransaction(config) 60 | 61 | const { isLoading, isSuccess, data: receipt, isError } = useWaitForTransaction({ 62 | hash: transaction?.hash, 63 | }) 64 | 65 | 66 | if (isLoading) return
Processing…
{transaction?.hash}
67 | if (isError) return
Transaction error!
68 | if (isSuccess) return
Success!
Lock Deployed at {receipt.logs[0].address}
69 | 70 | return ( 71 |
{ 74 | e.preventDefault() 75 | sendTransaction() 76 | }} 77 | > 78 | 79 |

Deploy a new membership contract!

80 | 81 |
82 | 83 | setName(e.target.value)} 87 | value={name} 88 | /> 89 |
90 | 91 |
92 | 93 | setDuration(e.target.value)} 97 | type="number" 98 | value={duration} 99 | /> 100 |
101 | 102 |
103 | 104 | setSupply(e.target.value)} 108 | type="number" 109 | value={supply} 110 | /> 111 |
112 | 113 |
114 | 115 | setCurrency(e.target.value)} 119 | type="text" 120 | value={currency} 121 | /> 122 |
123 | 124 |
125 | 126 | setPrice(e.target.value)} 130 | type="number" 131 | value={price} 132 | /> 133 |
134 | 135 | 138 |
139 | ) 140 | } 141 | 142 | 143 | export default DeployLock 144 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/PurchaseKey.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ethers } from 'ethers' 3 | import { PublicLockV11 } from "@unlock-protocol/contracts"; 4 | 5 | import { 6 | useAccount, 7 | useContractRead, 8 | useSendTransaction, 9 | useWaitForTransaction, 10 | usePrepareContractWrite, 11 | erc20ABI, 12 | useBalance 13 | } from 'wagmi' 14 | 15 | 16 | export function PurchaseKeyForm({ lock, setLock, currency, keyPrice, purchaser }) { 17 | const [recipient, setRecipient] = React.useState(purchaser) 18 | const [referrer, setReferrer] = React.useState(purchaser) 19 | const [manager, setManager] = React.useState(purchaser) 20 | 21 | const { data: currencyName } = useContractRead({ 22 | addressOrName: currency, 23 | contractInterface: erc20ABI, 24 | functionName: 'name', 25 | enabled: currency !== ethers.constants.AddressZero 26 | }) 27 | 28 | const { config, error: transactionPrepareError } = usePrepareContractWrite({ 29 | addressOrName: lock, 30 | contractInterface: PublicLockV11.abi, 31 | functionName: 'purchase', 32 | args: [ 33 | [keyPrice], 34 | [recipient], 35 | [referrer], 36 | [manager], 37 | [[]] 38 | ], 39 | overrides: { 40 | // We must set a value if the contract is using the base currency... 41 | // Otherwise, make sure the sender has approved the right amount of ERC20 42 | value: currency !== ethers.constants.AddressZero ? ethers.constants.Zero : keyPrice 43 | } 44 | }) 45 | 46 | const { data: transaction, sendTransaction } = useSendTransaction(config) 47 | 48 | const { isLoading, isSuccess, data: receipt, isError } = useWaitForTransaction({ 49 | hash: transaction?.hash, 50 | }) 51 | 52 | const { data: userERC20Balance } = useContractRead({ 53 | addressOrName: lock, 54 | contractInterface: erc20ABI, 55 | functionName: 'balanceOf', 56 | args: [purchaser], 57 | enabled: currency && currency !== ethers.constants.AddressZero 58 | }) 59 | 60 | const { data: { value: userBalance } } = useBalance({ 61 | addressOrName: purchaser, 62 | }) 63 | 64 | if (isLoading) return
Processing…
{transaction?.hash}
65 | if (isError) return
Transaction error!
66 | if (isSuccess) return
Success!
Purchased! {receipt.logs[0].address}
67 | 68 | let hasSufficientFunds = true 69 | if (currency !== ethers.constants.AddressZero) { 70 | hasSufficientFunds = userERC20Balance?.gte && userERC20Balance?.gte(keyPrice) 71 | } else { 72 | hasSufficientFunds = userBalance?.gte && userBalance?.gte(keyPrice) 73 | } 74 | 75 | 76 | const disabled = isLoading || transactionPrepareError || !hasSufficientFunds 77 | 78 | return ( 79 |
{ 82 | e.preventDefault() 83 | sendTransaction() 84 | }} 85 | > 86 |
87 | 88 | setLock(e.target.value)} 92 | value={lock} 93 | /> 94 |
95 | 96 |
97 | 98 | setRecipient(e.target.value)} 102 | type="text" 103 | value={recipient} 104 | /> 105 |
106 | 107 |
108 | 109 | setReferrer(e.target.value)} 113 | type="text" 114 | value={referrer} 115 | /> 116 |
117 | 118 |
119 | 120 | setManager(e.target.value)} 124 | type="text" 125 | value={manager} 126 | /> 127 |
128 | 129 | {hasSufficientFunds && transactionPrepareError &&

There is an error when preparing the transaction...

} 130 | {!hasSufficientFunds &&

The user does not have sufficient funds {currency === ethers.constants.AddressZero ? '' : `in ${currencyName}`} to pay for the membership

} 131 | 132 | 135 |
136 | ) 137 | } 138 | 139 | 140 | export function PurchaseKey() { 141 | const { address: purchaser } = useAccount() 142 | 143 | // DAI lock 0xC99794927355F7E3755Fe5fA1c45aA3cD3084e5d 144 | // Eth lock 0xBA570C1b9E70f63b9de9e9228c88cA58310b3a56 145 | const [lock, setLock] = React.useState('0xBA570C1b9E70f63b9de9e9228c88cA58310b3a56') 146 | 147 | const { data: currency } = useContractRead({ 148 | addressOrName: lock, 149 | contractInterface: PublicLockV11.abi, 150 | functionName: 'tokenAddress', 151 | }) 152 | 153 | const { data: keyPrice } = useContractRead({ 154 | addressOrName: lock, 155 | contractInterface: PublicLockV11.abi, 156 | functionName: 'keyPrice', 157 | }) 158 | 159 | if (ethers.utils.isAddress(lock) && (!currency || !keyPrice)) { 160 | return
Loading...
161 | } 162 | 163 | return ( 164 | 165 | ) 166 | } 167 | 168 | 169 | export default PurchaseKey 170 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | 13 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /examples/apps/react/wagmi.sh/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/**/*.{js,jsx,ts,tsx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/forms'), 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /examples/apps/unlockjs/subgraph-service.md: -------------------------------------------------------------------------------- 1 | # Using the SubgraphService to query locks: 2 | 3 | ```javascript 4 | const { SubgraphService } = require('..') 5 | 6 | async function main() { 7 | const service = new SubgraphService() 8 | const locks = await service.locks( 9 | { 10 | first: 100, 11 | skip: 100, 12 | }, 13 | { 14 | networks: [1, 5], 15 | } 16 | ) 17 | console.log(locks) 18 | 19 | const keys = await service.locks( 20 | { 21 | first: 100, 22 | skip: 100, 23 | }, 24 | { 25 | networks: [1, 5], 26 | } 27 | ) 28 | console.log(keys) 29 | } 30 | 31 | main().catch(console.error) 32 | ``` 33 | -------------------------------------------------------------------------------- /examples/paywall/magic/.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_live_**************** 2 | MAGIC_SECRET_KEY=sk_live_**************** 3 | -------------------------------------------------------------------------------- /examples/paywall/magic/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/paywall/magic/.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | -------------------------------------------------------------------------------- /examples/paywall/magic/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "quoteProps": "as-needed", 10 | "requirePragma": false, 11 | "semi": true, 12 | "singleQuote": false, 13 | "tabWidth": 2, 14 | "trailingComma": "es5", 15 | "useTabs": false 16 | } 17 | -------------------------------------------------------------------------------- /examples/paywall/magic/README.md: -------------------------------------------------------------------------------- 1 | # Magic Auth with Next.js - Starter Template 2 | 3 | This is the starter template for the Vercel guide "Add Auth to a Next.js Site with Magic.link" <-- link to be added. For reference, you can find the completed code [here](https://github.com/magiclabs/vercel-magic-guide). 4 | 5 | ## Get Started 6 | 7 | 1. Install dependencies. 8 | 9 | ```shell 10 | npm install 11 | # or 12 | yarn install 13 | ``` 14 | 15 | 2. Rename `.env.local.example` to `.env.local` and add your Magic Auth API keys. 16 | 17 | ```shell 18 | mv .env.local.example .env.local 19 | ``` 20 | 21 | ```javascript 22 | // .env.local 23 | 24 | NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY = "YOUR MAGIC AUTH PUBLISHABLE KEY"; 25 | MAGIC_SECRET_KEY = "YOUR MAGIC AUTH SECRET KEY"; 26 | ``` 27 | 28 | 1. Run the development server. 29 | 30 | ```shell 31 | npm run dev 32 | # or 33 | yarn dev 34 | ``` 35 | 36 | 4. Open http://localhost:3000 with your browser to see the result. 37 | -------------------------------------------------------------------------------- /examples/paywall/magic/lib/UserContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const UserContext = createContext(null); 4 | -------------------------------------------------------------------------------- /examples/paywall/magic/lib/magic.js: -------------------------------------------------------------------------------- 1 | import { Magic } from 'magic-sdk'; 2 | 3 | const createMagic = (key) => { 4 | // We make sure that the window object is available 5 | // Then we create a new instance of Magic using a publishable key 6 | return typeof window !== 'undefined' && new Magic(key); 7 | }; 8 | 9 | // Pass in your publishable key from your .env file 10 | export const magic = createMagic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY); 11 | -------------------------------------------------------------------------------- /examples/paywall/magic/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /examples/paywall/magic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vercel-magic-doc", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@magic-sdk/admin": "1.8.0", 13 | "@next/font": "13.1.6", 14 | "@unlock-protocol/networks": "^0.0.11", 15 | "@unlock-protocol/paywall": "0.6.6", 16 | "eslint": "8.33.0", 17 | "eslint-config-next": "13.1.6", 18 | "magic-sdk": "13.1.0", 19 | "next": "13.1.6", 20 | "react": "18.2.0", 21 | "react-dom": "18.2.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/paywall/magic/pages/_app.js: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import { useState, useEffect } from "react"; 3 | import { UserContext } from "../lib/UserContext"; 4 | import { useRouter } from "next/router"; 5 | import { magic } from "../lib/magic"; 6 | 7 | export default function App({ Component, pageProps }) { 8 | const [user, setUser] = useState(); 9 | // Create our router 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | // Set loading to true to display our loading message within pages/index.js 14 | setUser({ loading: true }); 15 | // Check if the user is authenticated already 16 | magic.user.isLoggedIn().then((isLoggedIn) => { 17 | if (isLoggedIn) { 18 | // Pull their metadata, update our state, and route to dashboard 19 | magic.user.getMetadata().then((userData) => setUser(userData)); 20 | router.push("/dashboard"); 21 | } else { 22 | // If false, route them to the login page and reset the user state 23 | router.push("/login"); 24 | setUser({ user: null }); 25 | } 26 | }); 27 | // Add an empty dependency array so the useEffect only runs once upon page load 28 | }, []); 29 | 30 | return ( 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /examples/paywall/magic/pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | {/* eslint-disable-next-line @next/next/no-title-in-document-head*/} 8 | Magic.link with Next.js 9 | 10 | 11 |
12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/paywall/magic/pages/api/login.js: -------------------------------------------------------------------------------- 1 | import { Magic } from '@magic-sdk/admin'; 2 | 3 | // Create an instance of magic admin using our secret key (not our publishable key) 4 | let mAdmin = new Magic(process.env.MAGIC_SECRET_KEY); 5 | 6 | export default async function login(req, res) { 7 | try { 8 | // Grab the DID token from our headers and parse it 9 | const didToken = mAdmin.utils.parseAuthorizationHeader( 10 | req.headers.authorization, 11 | ); 12 | // Validate the token and send back a successful response 13 | await mAdmin.token.validate(didToken); 14 | res.status(200).json({ authenticated: true }); 15 | } catch (error) { 16 | res.status(500).json({ error: error.message }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/paywall/magic/pages/dashboard.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { UserContext } from "../lib/UserContext"; 3 | import { magic } from "../lib/magic"; 4 | import { useRouter } from "next/router"; 5 | import { Paywall } from "@unlock-protocol/paywall"; 6 | import networks from "@unlock-protocol/networks"; 7 | 8 | export default function Dashboard() { 9 | const [user, setUser] = useContext(UserContext); 10 | // Create our router 11 | const router = useRouter(); 12 | 13 | const logout = () => { 14 | // Call Magic's logout method, reset the user state, and route to the login page 15 | magic.user.logout().then(() => { 16 | setUser({ user: null }); 17 | router.push("/login"); 18 | }); 19 | }; 20 | 21 | const checkout = async () => { 22 | const paywallConfig = { 23 | "locks": { 24 | "0xb77030a7e47a5eb942a4748000125e70be598632": { 25 | "network": 137, 26 | } 27 | }, 28 | "skipRecipient": true, 29 | "title": "My Membership", 30 | } 31 | const paywall = new Paywall(networks) 32 | await paywall.connect(magic.rpcProvider) 33 | await paywall.loadCheckoutModal(paywallConfig) 34 | // You can use the returned value above to get a transaction hash if needed! 35 | return false 36 | } 37 | 38 | return ( 39 | <> 40 | {user?.issuer && ( 41 | <> 42 |

Dashboard

43 |

Email

44 |

{user.email}

45 |

Wallet Address

46 |

{user.publicAddress}

47 | 48 | 49 | 50 | )} 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /examples/paywall/magic/pages/index.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { UserContext } from "../lib/UserContext"; 3 | 4 | export default function Home() { 5 | // Allow this component to access our user state 6 | const [user] = useContext(UserContext); 7 | 8 | return ( 9 |
10 | {/* Check to see if we are in a loading state and display a message if true */} 11 | {user?.loading &&

Loading...

} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /examples/paywall/magic/pages/login.js: -------------------------------------------------------------------------------- 1 | import { useContext, useState, useEffect } from "react"; 2 | import { UserContext } from "../lib/UserContext"; 3 | import { useRouter } from "next/router"; 4 | import { magic } from "../lib/magic"; 5 | 6 | export default function Login() { 7 | const [user, setUser] = useContext(UserContext); 8 | const [email, setEmail] = useState(""); 9 | // Create our router 10 | const router = useRouter(); 11 | 12 | // Make sure to add useEffect to your imports at the top 13 | useEffect(() => { 14 | // Check for an issuer on our user object. If it exists, route them to the dashboard. 15 | user?.issuer && router.push("/dashboard"); 16 | }, [user]); 17 | 18 | const handleLogin = async (e) => { 19 | e.preventDefault(); 20 | 21 | // Log in using our email with Magic and store the returned DID token in a variable 22 | try { 23 | const didToken = await magic.auth.loginWithMagicLink({ 24 | email, 25 | }); 26 | 27 | // Send this token to our validation endpoint 28 | const res = await fetch("/api/login", { 29 | method: "POST", 30 | headers: { 31 | "Content-type": "application/json", 32 | Authorization: `Bearer ${didToken}`, 33 | }, 34 | }); 35 | 36 | // If successful, update our user state with their metadata and route to the dashboard 37 | if (res.ok) { 38 | const userMetadata = await magic.user.getMetadata(); 39 | setUser(userMetadata); 40 | router.push("/dashboard"); 41 | } 42 | } catch (error) { 43 | console.error(error); 44 | } 45 | }; 46 | 47 | return ( 48 |
49 | 50 | setEmail(e.target.value)} 55 | /> 56 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /examples/paywall/magic/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unlock-protocol/examples/d5e95417ad35f75466b3c6cf16e71dbc2fe495b8/examples/paywall/magic/public/favicon.ico -------------------------------------------------------------------------------- /examples/paywall/magic/styles/globals.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | padding: 0; 4 | margin: 0; 5 | font-family: Arial, Helvetica, sans-serif; 6 | font-size: 62.5%; 7 | } 8 | 9 | html, 10 | body { 11 | max-width: 100vw; 12 | overflow-x: hidden; 13 | } 14 | 15 | body { 16 | text-align: center; 17 | margin: 10rem; 18 | display: flex; 19 | justify-content: center; 20 | } 21 | 22 | div { 23 | width: 55rem; 24 | border-radius: 0.75rem; 25 | padding: 5rem 0; 26 | background-color: #faf0e6; 27 | } 28 | 29 | h1 { 30 | font-size: 4rem; 31 | margin-bottom: 2.8rem; 32 | } 33 | 34 | h2 { 35 | font-size: 2.6rem; 36 | margin-bottom: 0.5rem; 37 | } 38 | 39 | p { 40 | font-size: 1.8rem; 41 | margin-bottom: 2.5rem; 42 | font-style: italic; 43 | } 44 | 45 | form { 46 | display: flex; 47 | flex-direction: column; 48 | align-items: center; 49 | } 50 | 51 | label { 52 | font-size: 2.8rem; 53 | font-weight: bold; 54 | letter-spacing: 0.05rem; 55 | } 56 | 57 | input { 58 | font-size: 1.8rem; 59 | width: 38rem; 60 | padding: 1rem 2.4rem; 61 | margin: 2rem 0; 62 | border: 1px solid rgb(123, 123, 123); 63 | outline: none; 64 | border-radius: 1rem; 65 | background-color: #fbf8f5; 66 | } 67 | 68 | button { 69 | background-color: #6851ff; 70 | color: white; 71 | border: none; 72 | border-radius: 2rem; 73 | margin-top: 2rem; 74 | padding: 1.2rem 4.8rem; 75 | font-size: 2rem; 76 | font-weight: bold; 77 | letter-spacing: 0.1rem; 78 | cursor: pointer; 79 | } 80 | 81 | button:active { 82 | background-color: #4e3cc0; 83 | } 84 | -------------------------------------------------------------------------------- /examples/paywall/provider/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /examples/paywall/provider/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | !.yarn/patches 3 | !.yarn/plugins 4 | !.yarn/releases 5 | !.yarn/sdks 6 | !.yarn/versions 7 | 8 | # Swap the comments on the following lines if you don't wish to use zero-installs 9 | # Documentation here: https://yarnpkg.com/features/zero-installs 10 | !.yarn/cache 11 | #.pnp.* 12 | .yarn 13 | -------------------------------------------------------------------------------- /examples/paywall/provider/README.md: -------------------------------------------------------------------------------- 1 | # create-lock-paywall 2 | 3 | This is an example to showcase how to use paywall provider to create a lock and charge users to access your content. 4 | 5 | ## Getting started 6 | 7 | 1. Clone this repository 8 | 2. Install dependencies: `yarn install` 9 | 3. Start the development server: `yarn dev` 10 | 11 | ## How it works 12 | 13 | We use paywall js to add an authentication modal to the page, when the user is authenticated, it returns a paywall provider which follows EIP-1193. We use this provider inside wagmi to create a lock and charge users to access the content. 14 | 15 | # License 16 | 17 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 18 | -------------------------------------------------------------------------------- /examples/paywall/provider/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | React + Vite App 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/paywall/provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "provider", 3 | "dependencies": { 4 | "@unlock-protocol/networks": "0.0.15", 5 | "@unlock-protocol/paywall": "0.6.1", 6 | "@unlock-protocol/unlock-js": "0.38.3", 7 | "buffer": "6.0.3", 8 | "ethers": "5.7.x", 9 | "react": "18.2.0", 10 | "react-dom": "18.2.0", 11 | "viem": "1.1.4", 12 | "wagmi": "1.2.1" 13 | }, 14 | "scripts": { 15 | "dev": "vite", 16 | "build": "vite build", 17 | "serve": "vite preview" 18 | }, 19 | "devDependencies": { 20 | "@esbuild-plugins/node-globals-polyfill": "0.2.3", 21 | "@vitejs/plugin-react": "4.0.1", 22 | "vite": "4.3.9", 23 | "vite-plugin-node-polyfills": "0.9.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/paywall/provider/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { WagmiConfig, createConfig, mainnet } from 'wagmi' 4 | import { createPublicClient, http } from 'viem' 5 | import { Profile } from './profile' 6 | 7 | const config = createConfig({ 8 | autoConnect: true, 9 | publicClient: createPublicClient({ 10 | chain: mainnet, 11 | transport: http(), 12 | }), 13 | }) 14 | 15 | function App() { 16 | return ( 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | const container = document.getElementById('root') 24 | const root = createRoot(container!) 25 | root.render() 26 | -------------------------------------------------------------------------------- /examples/paywall/provider/src/profile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useAccount, useConnect, useDisconnect } from 'wagmi' 3 | import { InjectedConnector } from 'wagmi/connectors/injected' 4 | import { Paywall } from '@unlock-protocol/paywall' 5 | import { networks } from '@unlock-protocol/networks' 6 | import { useMemo, useState } from 'react' 7 | import { WalletService } from '@unlock-protocol/unlock-js' 8 | import { ethers } from 'ethers' 9 | 10 | export function Profile() { 11 | const { address, isConnected } = useAccount() 12 | const provider = useMemo(() => { 13 | const paywall = new Paywall(networks) 14 | return paywall.getProvider('https://app.unlock-protocol.com', { 15 | clientId: 'http://localhost:5173/', 16 | }) 17 | }, []) 18 | 19 | const { connect } = useConnect({ 20 | connector: new InjectedConnector({ 21 | options: { 22 | name: 'Unlock Paywall Provider', 23 | getProvider: () => { 24 | return provider 25 | }, 26 | }, 27 | }), 28 | }) 29 | 30 | const { disconnect } = useDisconnect() 31 | const [isLoading, setIsLoading] = useState(false) 32 | 33 | if (isConnected) { 34 | return ( 35 |
36 | Connected to {address} 37 | 44 | 72 |
73 | ) 74 | } 75 | return ( 76 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /examples/paywall/provider/vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/README.md: -------------------------------------------------------------------------------- 1 | # Vite.js + Unlock Paywall's 2 | 3 | This is an example of how to add the Unlock Paywall library to a Vite Vanilla application. 4 | 5 | Here we only demonstrate the use of the Paywall for checkout with the following characteristics: 6 | 7 | - Checkout is loaded with a global config 8 | - The ability to "collect" hidden metadata which are linked to the user's membership: 9 | 10 | ```js 11 | window.unlockProtocolConfig = { 12 | locks: { 13 | '0xb77030a7e47a5eb942a4748000125e70be598632': { 14 | network: 137, 15 | }, 16 | }, 17 | metadataInputs: [ 18 | { 19 | name: 'email', 20 | type: 'email', 21 | required: true, 22 | }, 23 | { 24 | name: 'userId', 25 | type: 'hidden', 26 | required: true, 27 | value: '123', 28 | }, 29 | ], 30 | pessimistic: true, 31 | } 32 | ``` 33 | 34 | - Handling events: 35 | 36 | The Paywall library triggers events that can be used to know its state. 37 | 38 | ```javascript 39 | const events = [ 40 | 'unlockProtocol.transactionSent', 41 | 'unlockProtocol.status', 42 | 'unlockProtocol.authenticated', 43 | 'unlockProtocol.metadata', 44 | ] 45 | 46 | window.addEventListener(eventName, function (event) { 47 | console.group(`Received ${eventName}`) 48 | console.log(event.detail) 49 | console.groupEnd() 50 | }) 51 | ``` 52 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS 8 | 9 | 10 |
11 | 12 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "typescript": "^5.0.2", 13 | "vite": "^4.3.9" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/src/checkout.ts: -------------------------------------------------------------------------------- 1 | // Setting a global paywall config! 2 | 3 | declare global { 4 | interface Window { 5 | unlockProtocolConfig: any 6 | unlockProtocol: any 7 | } 8 | interface Event { 9 | detail: any 10 | } 11 | } 12 | 13 | export function setupCheckout(element: HTMLButtonElement) { 14 | // let counter = 0 15 | const checkout = () => { 16 | window.unlockProtocol.loadCheckoutModal() 17 | } 18 | element.addEventListener('click', () => checkout()) 19 | 20 | const events = [ 21 | 'unlockProtocol.transactionSent', 22 | 'unlockProtocol.status', 23 | 'unlockProtocol.authenticated', 24 | 'unlockProtocol.metadata', 25 | ] 26 | events.forEach((eventName) => { 27 | window.addEventListener(eventName, function (event) { 28 | console.group(`Received ${eventName}`) 29 | console.log(event.detail) 30 | console.groupEnd() 31 | }) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/src/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import typescriptLogo from './typescript.svg' 3 | import viteLogo from '/vite.svg' 4 | import { setupCheckout } from './checkout.ts' 5 | 6 | document.querySelector('#app')!.innerHTML = ` 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |

Vite + TypeScript

15 |
16 | 17 |
18 |
19 | ` 20 | 21 | setupCheckout(document.querySelector('#checkout')!) 22 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | #app { 40 | max-width: 1280px; 41 | margin: 0 auto; 42 | padding: 2rem; 43 | text-align: center; 44 | } 45 | 46 | .logo { 47 | height: 6em; 48 | padding: 1.5em; 49 | will-change: filter; 50 | transition: filter 300ms; 51 | } 52 | .logo:hover { 53 | filter: drop-shadow(0 0 2em #646cffaa); 54 | } 55 | .logo.vanilla:hover { 56 | filter: drop-shadow(0 0 2em #3178c6aa); 57 | } 58 | 59 | .card { 60 | padding: 2em; 61 | } 62 | 63 | .read-the-docs { 64 | color: #888; 65 | } 66 | 67 | button { 68 | border-radius: 8px; 69 | border: 1px solid transparent; 70 | padding: 0.6em 1.2em; 71 | font-size: 1em; 72 | font-weight: 500; 73 | font-family: inherit; 74 | background-color: #1a1a1a; 75 | cursor: pointer; 76 | transition: border-color 0.25s; 77 | } 78 | button:hover { 79 | border-color: #646cff; 80 | } 81 | button:focus, 82 | button:focus-visible { 83 | outline: 4px auto -webkit-focus-ring-color; 84 | } 85 | 86 | @media (prefers-color-scheme: light) { 87 | :root { 88 | color: #213547; 89 | background-color: #ffffff; 90 | } 91 | a:hover { 92 | color: #747bff; 93 | } 94 | button { 95 | background-color: #f9f9f9; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/src/typescript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/paywall/vanilla-js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | root: true, 5 | env: { browser: true, es2020: true }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 10 | 'plugin:react-hooks/recommended', 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | project: true, 17 | tsconfigRootDir: __dirname, 18 | }, 19 | plugins: ['react-refresh'], 20 | rules: { 21 | 'react-refresh/only-export-components': [ 22 | 'warn', 23 | { allowConstantExport: true }, 24 | ], 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/README.md: -------------------------------------------------------------------------------- 1 | # Wagmi + Paywall 2 | 3 | In this example we use [Wagmi](https://wagmi.sh/) in a React app (using [Vite](https://vitejs.dev/)) and use the unlock Paywall to build a checkout once the user is connected. 4 | 5 | We pass the `provider` from wagmi to the paywall object: 6 | 7 | ```js 8 | const checkout = async () => { 9 | const paywallConfig = { 10 | locks: { 11 | '0xb77030a7e47a5eb942a4748000125e70be598632': { 12 | network: 137, 13 | }, 14 | }, 15 | skipRecipient: true, 16 | title: 'My Membership', 17 | } 18 | await paywall.connect(await connector.getProvider()) 19 | await paywall.loadCheckoutModal(paywallConfig) 20 | // You can use the returned value above to get a transaction hash if needed! 21 | return false 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wagmi", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@unlock-protocol/networks": "^0.0.16", 14 | "@unlock-protocol/paywall": "^0.6.8", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "viem": "^1.2.9", 18 | "wagmi": "^1.3.7" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.2.14", 22 | "@types/react-dom": "^18.2.6", 23 | "@typescript-eslint/eslint-plugin": "^5.61.0", 24 | "@typescript-eslint/parser": "^5.61.0", 25 | "@vitejs/plugin-react": "^4.0.1", 26 | "eslint": "^8.44.0", 27 | "eslint-plugin-react-hooks": "^4.6.0", 28 | "eslint-plugin-react-refresh": "^0.4.1", 29 | "typescript": "^5.0.2", 30 | "vite": "^4.4.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { WagmiConfig, createConfig, mainnet } from 'wagmi' 2 | import { createPublicClient, http } from 'viem' 3 | import { Profile } from './Profile' 4 | 5 | const config = createConfig({ 6 | autoConnect: true, 7 | publicClient: createPublicClient({ 8 | chain: mainnet, 9 | transport: http(), 10 | }), 11 | }) 12 | 13 | function App() { 14 | return ( 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | export default App 22 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { useAccount, useConnect, useDisconnect } from 'wagmi' 2 | import { InjectedConnector } from 'wagmi/connectors/injected' 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore (this is a local package) 5 | import { Paywall } from '@unlock-protocol/paywall' 6 | import networks from '@unlock-protocol/networks' 7 | 8 | const paywall = new Paywall(networks) 9 | 10 | export function Profile() { 11 | const { address, isConnected, connector } = useAccount() 12 | const { connect } = useConnect({ 13 | connector: new InjectedConnector(), 14 | }) 15 | const { disconnect } = useDisconnect() 16 | 17 | const checkout = async () => { 18 | if (connector) { 19 | const paywallConfig = { 20 | locks: { 21 | '0xb77030a7e47a5eb942a4748000125e70be598632': { 22 | network: 137, 23 | }, 24 | }, 25 | skipRecipient: true, 26 | title: 'My Membership', 27 | } 28 | await paywall.connect(await connector.getProvider()) 29 | await paywall.loadCheckoutModal(paywallConfig) 30 | } 31 | // You can use the returned value above to get a transaction hash if needed! 32 | return false 33 | } 34 | 35 | if (isConnected) 36 | return ( 37 |
38 | Connected to {address} 39 | 40 | 41 |
42 | ) 43 | return 44 | } 45 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | button { 40 | border-radius: 8px; 41 | border: 1px solid transparent; 42 | padding: 0.6em 1.2em; 43 | font-size: 1em; 44 | font-weight: 500; 45 | font-family: inherit; 46 | background-color: #1a1a1a; 47 | cursor: pointer; 48 | transition: border-color 0.25s; 49 | } 50 | button:hover { 51 | border-color: #646cff; 52 | } 53 | button:focus, 54 | button:focus-visible { 55 | outline: 4px auto -webkit-focus-ring-color; 56 | } 57 | 58 | @media (prefers-color-scheme: light) { 59 | :root { 60 | color: #213547; 61 | background-color: #ffffff; 62 | } 63 | a:hover { 64 | color: #747bff; 65 | } 66 | button { 67 | background-color: #f9f9f9; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/paywall/wagmi/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | typechain-types 7 | 8 | #Hardhat files 9 | cache 10 | artifacts 11 | 12 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unlock-protocol/examples/d5e95417ad35f75466b3c6cf16e71dbc2fe495b8/examples/solidity/hooks/discount-hook/.yarn/install-state.gz -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/README.md: -------------------------------------------------------------------------------- 1 | # Discount Hook for Locks 2 | 3 | This project implements an Unlock [PublicLock Hook](https://docs.unlock-protocol.com/core-protocol/public-lock/hooks) that can be used on Locks smart contracts to support a discount code or coupon. 4 | 5 | This process is _secured_ and cannot be bypassed by calling the contract directly as the discount code is used to submit the transaction on-chain. 6 | 7 | When the user enters a discount code on the frontend application, it is used to generate a private key that is then used to sign the recipient's address. That signature is passed as the data argument on the `purchase` call. 8 | 9 | A lock manager can add any number of discount code to their lock by calling the function `setDiscountCodeForLock` multiple times. 10 | 11 | The Unlock Protocol team has deployed and verified a version of this hook on the following networks: 12 | 13 | Production networks: 14 | 15 | - [Optimism: `0x8e0B46ec3B95c81355175693dA0083b00fCc1326`](https://optimistic.etherscan.io/address/0x8e0B46ec3B95c81355175693dA0083b00fCc1326) 16 | - [Polygon: `0x93E160838c529873cB7565106bBb79a3226FE07A`](https://polygonscan.com/address/0x93E160838c529873cB7565106bBb79a3226FE07A) 17 | 18 | Test networks: 19 | 20 | - [Goerli: `0x850c015A6A88756a59Dc025fca988494fF90DBB7`](https://goerli.etherscan.io/address/0x850c015A6A88756a59Dc025fca988494fF90DBB7) 21 | 22 | ## Example 23 | 24 | [This example lock](https://goerli.etherscan.io/address/0x2490f447fdb7b259bc454871806b6b794de65944) is deployed on Goerli and uses this discount hook with 2 different discounts: 25 | 26 | - `FRIENDS` for a 50% discount 27 | - `FAMILY` for a 100% discount 28 | 29 | This means if you purchase a key [through this checkout URL](https://app.unlock-protocol.com/checkout?paywallConfig=%7B%22locks%22%3A%7B%220x2490f447fdb7b259bc454871806b6b794de65944%22%3A%7B%22network%22%3A5%2C%22name%22%3A%22%22%2C%22captcha%22%3Afalse%2C%22password%22%3Afalse%2C%22promo%22%3Atrue%2C%22emailRequired%22%3Afalse%2C%22maxRecipients%22%3Anull%2C%22dataBuilder%22%3A%22%22%2C%22skipRecipient%22%3Afalse%7D%7D%2C%22pessimistic%22%3Atrue%2C%22skipRecipient%22%3Atrue%2C%22title%22%3A%22Try+Discounts%21%22%2C%22icon%22%3A%22%22%7D) and enter any of the 2 discount codes you will see a discounted price on the final confirmation screen! 30 | 31 | If you don't enter a discount code, you will pay the full price of 0.01 Eth. 32 | 33 | ## Using the hook for your own lock 34 | 35 | 1. First, you need to generate promo codes, then [go to this page to generate the corresponding signer address](https://unlock-protocol.github.io/discount-hook/). You can also generate this locally if needed by checking out the repo and switching to the `gh-page` branch. 36 | 37 | Screen Shot 2023-01-09 at 12 55 04 PM 38 | 39 | 40 | 2. Then, click on the network your lock has been deployed on (list above) and head to `Contract` > `Write Contract`. Connect your wallet (you need to be connected as one of the lock's manager) and click on `setDiscountForLock`. There, enter the lock address, and then the signer address generated in the previous step and the discount amount to be applied. Since the EVM does not support floating numbers, you have to enter the discount percentage in [basis points](https://en.wikipedia.org/wiki/Basis_point). For example for a 100% discount, you would enter `10000`. For a 3% discount, you would enter `300`. 41 | 42 | Screen Shot 2023-01-09 at 12 56 45 PM 43 | 44 | 3. After that, you need to point your lock to the hook. You can do that by going to your lock's settings page on the Unlock Dashboard. Then check the Advanced tab and the Hooks section. Add the address of the key purchase hook for the network your lock is deployed on. You can find a list of all the key purchase hook addresses listed above (and please get in touch with us if you need it to be deployed on more networks). 45 | 46 | Screen Shot 2023-01-09 at 12 57 19 PM 47 | 48 | 4. Finally, [build a Checkout URL](https://unlock-protocol.com/blog/checkout-builder-release) and make sure you tick `Promo Codes` option for the lock to which you are applying a discount! 49 | 50 | Screen Shot 2023-01-09 at 12 57 52 PM 51 | 52 | 53 | ## Dev 54 | 55 | You can deploy the hook on other chains by adding the chain to the `hardhat.config.js` config file and calling: 56 | 57 | ``` 58 | yarn run hardhat run scripts/deploy.js --network my-network 59 | ``` 60 | 61 | To verify the contract on block explorers, call : 62 | 63 | ``` 64 | yarn run hardhat verify --network my-network 0xhook-address 65 | ``` 66 | 67 | Running tests: 68 | 69 | ``` 70 | yarn run hardhat test test/sample-test.js 71 | ``` 72 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/contracts/DiscountHook.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 4 | import "@unlock-protocol/contracts/dist/PublicLock/IPublicLockV12.sol"; 5 | 6 | // Uncomment this line to use console.log 7 | // import "hardhat/console.sol"; 8 | 9 | error TOO_BIG(); 10 | error NOT_AUTHORIZED(); 11 | 12 | contract DiscountHook { 13 | 14 | // mapping of lock address, to address for password to keyPrice 15 | mapping(address => mapping (address => uint)) public discounts; 16 | 17 | 18 | constructor() { 19 | } 20 | 21 | // discount is expressed in basis points (ie 100% is 10000) 22 | function setDiscountForLock(address lock, address signer, uint discount) public { 23 | if (discount > 10000) { 24 | revert TOO_BIG(); 25 | } 26 | if (!IPublicLockV12(lock).isLockManager(msg.sender)) { 27 | revert NOT_AUTHORIZED(); 28 | } 29 | discounts[lock][signer] = discount; 30 | } 31 | 32 | /** 33 | * Price is the same for everyone... 34 | * but we fail if signer of data does not match the lock's password. 35 | */ 36 | function keyPurchasePrice( 37 | address, /* from */ 38 | address recipient, 39 | address, /* referrer */ 40 | bytes calldata signature /* data */ 41 | ) external view returns (uint256 minKeyPrice) { 42 | uint keyPrice = IPublicLockV12(msg.sender).keyPrice(); 43 | address signer = getSigner(toString(recipient), signature); 44 | uint discount = discounts[msg.sender][signer]; 45 | if (discount > 0) { 46 | // Overflow? 47 | return keyPrice - (keyPrice * discount) / 10000; 48 | } 49 | return keyPrice; 50 | } 51 | 52 | /** 53 | * No-op but required for the hook to work 54 | */ 55 | function onKeyPurchase( 56 | uint256, /* tokenId */ 57 | address, /* from */ 58 | address, /* recipient */ 59 | address, /* referrer */ 60 | bytes calldata, /* data */ 61 | uint256, /* minKeyPrice */ 62 | uint256 /* pricePaid */ 63 | ) external { 64 | // NO OP 65 | return; 66 | } 67 | 68 | /** 69 | * Debug function 70 | */ 71 | function getSigner( 72 | string memory message, 73 | bytes calldata signature 74 | ) public view returns (address recoveredAddress) { 75 | if (signature.length != 65) { 76 | return address(0); 77 | } 78 | bytes32 hash = keccak256(abi.encodePacked(message)); 79 | bytes32 signedMessageHash = ECDSA.toEthSignedMessageHash(hash); 80 | return ECDSA.recover(signedMessageHash, signature); 81 | } 82 | 83 | /** 84 | * Helper functions to turn address into string so we can verify 85 | * the signature (address is signed as string on the client) 86 | */ 87 | function toString(address account) public pure returns (string memory) { 88 | return toString(abi.encodePacked(account)); 89 | } 90 | 91 | function toString(uint256 value) public pure returns (string memory) { 92 | return toString(abi.encodePacked(value)); 93 | } 94 | 95 | function toString(bytes32 value) public pure returns (string memory) { 96 | return toString(abi.encodePacked(value)); 97 | } 98 | 99 | function toString(bytes memory data) public pure returns (string memory) { 100 | bytes memory alphabet = "0123456789abcdef"; 101 | 102 | bytes memory str = new bytes(2 + data.length * 2); 103 | str[0] = "0"; 104 | str[1] = "x"; 105 | for (uint256 i = 0; i < data.length; i++) { 106 | str[2 + i * 2] = alphabet[uint256(uint8(data[i] >> 4))]; 107 | str[3 + i * 2] = alphabet[uint256(uint8(data[i] & 0x0f))]; 108 | } 109 | return string(str); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from 'hardhat/config' 2 | import '@nomicfoundation/hardhat-toolbox' 3 | import '@unlock-protocol/hardhat-plugin' 4 | import '@nomiclabs/hardhat-etherscan' 5 | import networks from '@unlock-protocol/networks' 6 | 7 | let accounts: string[] = [] 8 | if (process.env.PKEY) { 9 | accounts.push(process.env.PKEY) 10 | } 11 | 12 | const networksByNames = Object.keys(networks).reduce((acc, networkId) => { 13 | const network = networks[networkId] 14 | return { 15 | ...acc, 16 | [network.chain]: { 17 | accounts, 18 | url: network.provider, 19 | }, 20 | } 21 | }, {}) 22 | 23 | const config: HardhatUserConfig = { 24 | solidity: '0.8.17', 25 | networks: networksByNames, 26 | etherscan: { 27 | apiKey: { 28 | polygon: 'W9TVEYKW2CDTQ94T3A2V93IX6U3IHQN5Y3', 29 | goerli: 'HPSH1KQDPJTNAPU3335G931SC6Y3ZYK3BF', 30 | mainnet: 'HPSH1KQDPJTNAPU3335G931SC6Y3ZYK3BF', 31 | rinkeby: 'HPSH1KQDPJTNAPU3335G931SC6Y3ZYK3BF', 32 | bsc: '6YUDRP3TFPQNRGGZQNYAEI1UI17NK96XGK', 33 | xdai: 'api-key', 34 | optimisticEthereum: 'UYVMUG3JUSGAJC2Q62SDU9CYSVXHCYQ8NU', 35 | }, 36 | }, 37 | } 38 | 39 | export default config 40 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hardhat-tutorial", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "@ethersproject/abi": "^5.4.7", 8 | "@ethersproject/providers": "^5.4.7", 9 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.0", 10 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 11 | "@nomicfoundation/hardhat-toolbox": "^2.0.0", 12 | "@nomiclabs/hardhat-ethers": "^2.0.0", 13 | "@nomiclabs/hardhat-etherscan": "^3.0.0", 14 | "@typechain/ethers-v5": "^10.1.0", 15 | "@typechain/hardhat": "^6.1.2", 16 | "@types/chai": "^4.2.0", 17 | "@types/mocha": "^9.1.0", 18 | "@types/node": ">=12.0.0", 19 | "chai": "^4.2.0", 20 | "ethers": "^5.4.7", 21 | "hardhat": "^2.12.5", 22 | "hardhat-deploy": "^0.11.31", 23 | "hardhat-gas-reporter": "^1.0.8", 24 | "solidity-coverage": "^0.8.0", 25 | "ts-node": ">=8.0.0", 26 | "typechain": "^8.1.0", 27 | "typescript": "5.1.3" 28 | }, 29 | "dependencies": { 30 | "@openzeppelin/contracts": "^4.8.0", 31 | "@unlock-protocol/contracts": "^0.0.15", 32 | "@unlock-protocol/hardhat-plugin": "^0.0.18", 33 | "@unlock-protocol/networks": "^0.0.9" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "hardhat"; 2 | 3 | async function main() { 4 | const [signer] = await ethers.getSigners(); 5 | console.log(`Deploying from ${signer.address}`); 6 | 7 | const DiscountHook = await ethers.getContractFactory("DiscountHook"); 8 | const discountHook = await DiscountHook.deploy(); 9 | 10 | await discountHook.deployed(); 11 | 12 | console.log(`Deployed to ${discountHook.address}`); 13 | } 14 | 15 | // We recommend this pattern to be able to use async/await everywhere 16 | // and properly handle errors. 17 | main().catch((error) => { 18 | console.error(error); 19 | process.exitCode = 1; 20 | }); 21 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/test/DiscountHook.ts: -------------------------------------------------------------------------------- 1 | import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; 2 | import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; 3 | import { expect } from "chai"; 4 | import { ethers, unlock } from "hardhat"; 5 | 6 | /** 7 | * Helper function 8 | * @param {*} password 9 | * @param {*} message 10 | * @returns 11 | */ 12 | const getSignatureForPassword = async ( 13 | password: string, 14 | message: string 15 | ): Promise<[string, string]> => { 16 | // Build the signer 17 | const encoded = ethers.utils.defaultAbiCoder.encode( 18 | ["bytes32"], 19 | [ethers.utils.id(password)] 20 | ); 21 | const privateKey = ethers.utils.keccak256(encoded); 22 | const privateKeyAccount = new ethers.Wallet(privateKey); 23 | 24 | // Sign 25 | const messageHash = ethers.utils.solidityKeccak256(["string"], [message]); 26 | const messageHashBinary = ethers.utils.arrayify(messageHash); 27 | const signature = await privateKeyAccount.signMessage(messageHashBinary); 28 | const verified = ethers.utils.verifyMessage(messageHashBinary, signature); 29 | 30 | return [signature, privateKeyAccount.address]; 31 | }; 32 | 33 | describe("DiscountHook", function () { 34 | it("should compute signers correctly", async function () { 35 | const recipient = "0xF5C28ce24cf47849988f147d5C75787c0103534".toLowerCase(); 36 | 37 | const password = "password"; // (Math.random()).toString(36).substring(2); 38 | const DiscountHook = await ethers.getContractFactory("DiscountHook"); 39 | const hook = await DiscountHook.deploy(); 40 | await hook.deployed(); 41 | 42 | const [data, signerAddress] = await getSignatureForPassword( 43 | password, 44 | recipient 45 | ); 46 | 47 | // with wrong password 48 | const [badData, _] = await getSignatureForPassword( 49 | "wrongpassword", 50 | recipient 51 | ); 52 | expect(await hook.getSigner(recipient.toLowerCase(), badData)).to.not.equal( 53 | signerAddress 54 | ); 55 | 56 | // with correct password 57 | expect(await hook.getSigner(recipient.toLowerCase(), data)).to.equal( 58 | signerAddress 59 | ); 60 | }); 61 | 62 | it("should allow purchases with discounts if the code is correct", async () => { 63 | const [user] = await ethers.getSigners(); 64 | 65 | await unlock.deployProtocol(); 66 | const expirationDuration = 60 * 60 * 24 * 7; 67 | const maxNumberOfKeys = 100; 68 | const keyPrice = ethers.utils.parseUnits("1.0", "ether"); 69 | const { lock } = await unlock.createLock({ 70 | expirationDuration, 71 | maxNumberOfKeys, 72 | keyPrice, 73 | name: "ticket", 74 | currencyContractAddress: "", 75 | }); 76 | const DiscountHook = await ethers.getContractFactory("DiscountHook"); 77 | const hook = await DiscountHook.deploy(); 78 | await hook.deployed(); 79 | 80 | await ( 81 | await lock.setEventHooks( 82 | hook.address, 83 | ethers.constants.AddressZero, 84 | ethers.constants.AddressZero, 85 | ethers.constants.AddressZero, 86 | ethers.constants.AddressZero, 87 | ethers.constants.AddressZero, 88 | ethers.constants.AddressZero 89 | ) 90 | ).wait(); 91 | 92 | // Build the signer from password 93 | const password = "50%OFF"; 94 | const [data, signer] = await getSignatureForPassword( 95 | password, 96 | user.address.toLowerCase() 97 | ); 98 | // Set the password on the hook for the lock 99 | const discount = 50; 100 | await ( 101 | await hook.setDiscountForLock(lock.address, signer, discount * 100) 102 | ).wait(); 103 | 104 | const priceWithoutPassword = await lock.purchasePriceFor( 105 | user.address, 106 | user.address, 107 | 0 108 | ); 109 | expect(priceWithoutPassword).equal(await lock.keyPrice()); 110 | 111 | const priceWithPassword = await lock.purchasePriceFor( 112 | user.address, 113 | user.address, 114 | data 115 | ); 116 | expect(priceWithPassword).equal( 117 | (await lock.keyPrice()).mul(discount).div(100) 118 | ); 119 | 120 | const balanceBefore = await ethers.provider.getBalance(user.address); 121 | console.log(ethers.utils.formatEther(balanceBefore)); 122 | 123 | // And now let's do a full purchase which should *just work* 124 | // And now make a purchase that should fail because we did not submit a data 125 | await expect( 126 | lock.purchase( 127 | [keyPrice], 128 | [user.address], 129 | [user.address], 130 | [user.address], 131 | [data], 132 | { value: keyPrice.mul(discount).div(100) } 133 | ) 134 | ).not.to.reverted; 135 | 136 | const balanceAfter = await ethers.provider.getBalance(user.address); 137 | console.log(ethers.utils.formatEther(balanceAfter)); 138 | expect(balanceAfter).to.be.greaterThan(balanceBefore.sub(keyPrice)); // becuase the user got a discount! 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /examples/solidity/hooks/discount-hook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | typechain-types 7 | 8 | #Hardhat files 9 | cache 10 | artifacts 11 | 12 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/README.md: -------------------------------------------------------------------------------- 1 | # The Guild Hook 2 | 3 | The Guild hook is a contract to be used as an `onKeyPurchase` [Hook with a membership contract](https://docs.unlock-protocol.com/core-protocol/public-lock/hooks). It requires Unlock's backend service (called `Locksmith`) to verify that the purchaser of a given membership NFT belongs to a specific [guild.xyz](https://guild.xyz/) guild. If so, `Locksmith` provides a signed message that gets passed to the `purchase` method on the lock, and then to this `GuildHook` contract. The contract will verify the signature and revert if it does not match! 4 | 5 | This Hook is in fact identical to the Captcha hook. 6 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/contracts/GuildHook.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; 4 | import '@unlock-protocol/contracts/dist/PublicLock/IPublicLockV13.sol'; 5 | import '@openzeppelin/contracts/access/Ownable.sol'; 6 | 7 | contract GuildHook is Ownable { 8 | mapping(address => bool) public signers; 9 | 10 | constructor() {} 11 | 12 | function addSigner(address signer) public onlyOwner { 13 | signers[signer] = true; 14 | } 15 | 16 | function removeSigner(address signer) public onlyOwner { 17 | signers[signer] = false; 18 | } 19 | 20 | /** 21 | * Price is the same for everyone... but we fail if signature by Unlock Lab's backend service (sent as signature) does not match! 22 | * The Unlock Labs backend service verifies that the purchaser is a member of the guild and signs the address of the recipient to confirm. 23 | */ 24 | function keyPurchasePrice( 25 | address /* from */, 26 | address recipient, 27 | address /* referrer */, 28 | bytes calldata signature /* data */ 29 | ) external view returns (uint256 minKeyPrice) { 30 | string memory message = toString(recipient); 31 | require(checkIsSigner(message, signature), 'WRONG_SIGNATURE'); 32 | if (address(msg.sender).code.length > 0) { 33 | return IPublicLockV13(msg.sender).keyPrice(); 34 | } 35 | return 0; 36 | } 37 | 38 | /** 39 | * Debug function 40 | */ 41 | function checkIsSigner( 42 | string memory message, 43 | bytes calldata signature /* data */ 44 | ) private view returns (bool isSigner) { 45 | bytes memory encoded = abi.encodePacked(message); 46 | bytes32 messageHash = keccak256(encoded); 47 | bytes32 hash = ECDSA.toEthSignedMessageHash(messageHash); 48 | address recoveredAddress = ECDSA.recover(hash, signature); 49 | return signers[recoveredAddress]; 50 | } 51 | 52 | /** 53 | * Helper functions to turn addrerss into string so we can verify the signature (address is signed as string on the client) 54 | */ 55 | function toString(address account) private pure returns (string memory) { 56 | return toString(abi.encodePacked(account)); 57 | } 58 | 59 | function toString(uint256 value) private pure returns (string memory) { 60 | return toString(abi.encodePacked(value)); 61 | } 62 | 63 | function toString(bytes32 value) private pure returns (string memory) { 64 | return toString(abi.encodePacked(value)); 65 | } 66 | 67 | function toString(bytes memory data) private pure returns (string memory) { 68 | bytes memory alphabet = '0123456789abcdef'; 69 | 70 | bytes memory str = new bytes(2 + data.length * 2); 71 | str[0] = '0'; 72 | str[1] = 'x'; 73 | for (uint256 i = 0; i < data.length; i++) { 74 | str[2 + i * 2] = alphabet[uint256(uint8(data[i] >> 4))]; 75 | str[3 + i * 2] = alphabet[uint256(uint8(data[i] & 0x0f))]; 76 | } 77 | return string(str); 78 | } 79 | 80 | /** 81 | * No-op but required 82 | */ 83 | function onKeyPurchase( 84 | uint256 /* tokenId */, 85 | address /* from */, 86 | address /* recipient */, 87 | address /* referrer */, 88 | bytes calldata /* data */, 89 | uint256 /* minKeyPrice */, 90 | uint256 /* pricePai d*/ 91 | ) external { 92 | /** no-op. this should have failed earlier if data is not the right signature */ 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import '@unlock-protocol/hardhat-plugin' 2 | import { HardhatUserConfig } from 'hardhat/config' 3 | import '@nomicfoundation/hardhat-toolbox' 4 | import networks from '@unlock-protocol/networks' 5 | 6 | let accounts: string[] = [] 7 | if (process.env.PKEY) { 8 | accounts.push(process.env.PKEY) 9 | } 10 | 11 | const networksByNames = Object.keys(networks).reduce((acc, networkId) => { 12 | const network = networks[networkId] 13 | return { 14 | ...acc, 15 | [network.chain]: { 16 | accounts, 17 | url: network.provider, 18 | }, 19 | } 20 | }, {}) 21 | 22 | const config: HardhatUserConfig = { 23 | solidity: '0.8.17', 24 | networks: networksByNames, 25 | etherscan: { 26 | apiKey: { 27 | arbitrumOne: 'W5XNFPZS8D6JZ5AXVWD4XCG8B5ZH5JCD4Y', 28 | gnosis: 'BSW3C3NDUUBWSQZJ5FUXBNXVYX92HZDDCV', 29 | polygon: 'W9TVEYKW2CDTQ94T3A2V93IX6U3IHQN5Y3', 30 | goerli: 'HPSH1KQDPJTNAPU3335G931SC6Y3ZYK3BF', 31 | mainnet: 'HPSH1KQDPJTNAPU3335G931SC6Y3ZYK3BF', 32 | rinkeby: 'HPSH1KQDPJTNAPU3335G931SC6Y3ZYK3BF', 33 | bsc: '6YUDRP3TFPQNRGGZQNYAEI1UI17NK96XGK', 34 | xdai: 'api-key', 35 | optimisticEthereum: 'V51DWC44XURIGPP49X85VZQGH1DCBAW5EC', 36 | }, 37 | }, 38 | } 39 | 40 | export default config 41 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hardhat-project", 3 | "devDependencies": { 4 | "@nomicfoundation/hardhat-toolbox": "^2.0.2", 5 | "hardhat": "^2.14.1" 6 | }, 7 | "dependencies": { 8 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.0", 9 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 10 | "@nomiclabs/hardhat-ethers": "^2.0.0", 11 | "@nomiclabs/hardhat-etherscan": "^3.0.0", 12 | "@openzeppelin/contracts": "^4.9.0", 13 | "@typechain/ethers-v5": "^10.1.0", 14 | "@typechain/hardhat": "^6.1.2", 15 | "@types/chai": "^4.2.0", 16 | "@types/mocha": ">=9.1.0", 17 | "@unlock-protocol/contracts": "^0.0.22", 18 | "@unlock-protocol/hardhat-plugin": "^0.0.18", 19 | "@unlock-protocol/networks": "^0.0.15", 20 | "chai": "^4.2.0", 21 | "hardhat-gas-reporter": "^1.0.8", 22 | "solidity-coverage": "^0.8.1", 23 | "ts-node": "^10.9.1", 24 | "typechain": "^8.1.0", 25 | "typescript": "^5.1.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | const { getChainId } = require('hardhat') 2 | const networks = require('@unlock-protocol/networks') 3 | 4 | async function main() { 5 | const { chainId } = await ethers.getDefaultProvider().getNetwork() 6 | const unlockNetworkName = Object.keys(networks).filter((name) => { 7 | return networks[name].id === chainId 8 | })[0] 9 | 10 | if (!unlockNetworkName) { 11 | return console.error('No Unlock network found for chainId', chainId) 12 | } 13 | const unlockNetwork = networks[unlockNetworkName] 14 | const [user] = await ethers.getSigners() 15 | 16 | if (!user) { 17 | return console.error('No user. Please set the PKEY env var') 18 | } 19 | 20 | console.log('Deploying from', user.address) 21 | 22 | // We get the contract to deploy 23 | const signers = [ 24 | '0x22c095c69c38b66afAad4eFd4280D94Ec9D12f4C', 25 | '0x903073735Bb6FDB802bd3CDD3b3a2b00C36Bc2A9', 26 | ] 27 | 28 | const PurchaseHook = await ethers.getContractFactory('GuildHook') 29 | const hook = await PurchaseHook.deploy() 30 | 31 | await hook.deployed() 32 | 33 | console.log('Hook deployed to:', hook.address) 34 | for (let i = 0; i < signers.length; i++) { 35 | console.log('Adding signer:', signers[i]) 36 | await hook.addSigner(signers[i]) 37 | } 38 | if (unlockNetwork.multisig) { 39 | await hook.transferOwnership(unlockNetwork.multisig) 40 | } 41 | console.log( 42 | 'Transfering ownership to multisig signer:', 43 | unlockNetwork.multisig 44 | ) 45 | } 46 | 47 | main() 48 | .then(() => process.exit(0)) 49 | .catch((error) => { 50 | console.error(error) 51 | process.exit(1) 52 | }) 53 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/test/GuildHook.ts: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai') 2 | const { ethers, unlock } = require('hardhat') 3 | 4 | describe('GuildHook', function () { 5 | it('Should work', async function () { 6 | const [user] = await ethers.getSigners() 7 | const signer = ethers.Wallet.createRandom() 8 | const sender = '0xF5C28ce24Acf47849988f147d5C75787c0103534'.toLowerCase() 9 | 10 | const GuildHook = await ethers.getContractFactory('GuildHook') 11 | const hook = await GuildHook.deploy() 12 | await hook.deployed() 13 | 14 | await hook.addSigner(signer.address) 15 | 16 | // signing wrong message 17 | expect( 18 | await hook.checkIsSigner(sender, await signer.signMessage('hello')) 19 | ).to.equal(false) 20 | expect( 21 | await hook.checkIsSigner('hello', await signer.signMessage(sender)) 22 | ).to.equal(false) 23 | 24 | // wrong signer 25 | expect( 26 | await hook.checkIsSigner(sender, await user.signMessage(sender)) 27 | ).to.equal(false) 28 | 29 | // Correct signer, correct message 30 | const message = 'hello' 31 | const messageHash = ethers.utils.solidityKeccak256( 32 | ['string'], 33 | [message.toLowerCase()] 34 | ) 35 | const signedMessage = await signer.signMessage( 36 | ethers.utils.arrayify(messageHash) 37 | ) 38 | expect(ethers.utils.verifyMessage(message, signedMessage), signer.address) 39 | expect(await hook.checkIsSigner(message, signedMessage)).to.equal(true) 40 | }) 41 | 42 | it('should work as a hook', async function () { 43 | const [user, another, aThird] = await ethers.getSigners() 44 | const signer = ethers.Wallet.createRandom() 45 | 46 | await unlock.deployProtocol() 47 | const expirationDuration = 60 * 60 * 24 * 7 48 | const maxNumberOfKeys = 100 49 | const keyPrice = 0 50 | 51 | const { lock } = await unlock.createLock({ 52 | expirationDuration, 53 | maxNumberOfKeys, 54 | keyPrice, 55 | name: 'ticket', 56 | }) 57 | const GuildHook = await ethers.getContractFactory('GuildHook') 58 | const hook = await GuildHook.deploy() 59 | await hook.deployed() 60 | await hook.addSigner(signer.address) 61 | 62 | // Set the hook on avatar 63 | await ( 64 | await lock.setEventHooks( 65 | hook.address, 66 | ethers.constants.AddressZero, 67 | ethers.constants.AddressZero, 68 | ethers.constants.AddressZero, 69 | ethers.constants.AddressZero, 70 | ethers.constants.AddressZero, 71 | ethers.constants.AddressZero 72 | ) 73 | ).wait() 74 | 75 | const messageHash = ethers.utils.solidityKeccak256( 76 | ['string'], 77 | [user.address.toLowerCase()] 78 | ) 79 | const signedMessage = await signer.signMessage( 80 | ethers.utils.arrayify(messageHash) 81 | ) 82 | 83 | const anotherMessageHash = ethers.utils.solidityKeccak256( 84 | ['string'], 85 | [another.address.toLowerCase()] 86 | ) 87 | const anotherSignedMessage = await signer.signMessage( 88 | ethers.utils.arrayify(anotherMessageHash) 89 | ) 90 | 91 | // Health check! 92 | expect( 93 | ethers.utils.verifyMessage(user.address.toLowerCase(), signedMessage), 94 | signer.address 95 | ) 96 | expect( 97 | await hook.checkIsSigner(user.address.toLowerCase(), signedMessage) 98 | ).to.equal(true) 99 | 100 | // Let's now purchase a key! 101 | const tx = await lock.purchase( 102 | [0], 103 | [user.address, another.address], 104 | [user.address, another.address], 105 | [user.address, another.address], 106 | [signedMessage, anotherSignedMessage] 107 | ) 108 | const receipt = await tx.wait() 109 | 110 | // Let's now purchase a key with the wrong signed message 111 | await expect( 112 | lock.purchase( 113 | [0], 114 | [aThird.address], 115 | [aThird.address], 116 | [aThird.address], 117 | [signedMessage] 118 | ) 119 | ).to.revertedWith('WRONG_SIGNATURE') 120 | 121 | // Let's now purchase a key with no signed message 122 | await expect( 123 | lock.purchase( 124 | [0], 125 | [aThird.address], 126 | [aThird.address], 127 | [aThird.address], 128 | [[]] 129 | ) 130 | ).to.revertedWith('ECDSA: invalid signature length') 131 | }) 132 | 133 | it('should be able to add and remove signers from owner', async () => { 134 | const [user, anotherUser] = await ethers.getSigners() 135 | const signer = ethers.Wallet.createRandom() 136 | const GuildHook = await ethers.getContractFactory('GuildHook') 137 | const hook = await GuildHook.deploy() 138 | await hook.deployed() 139 | 140 | // Add a signer 141 | await hook.addSigner(signer.address) 142 | expect(await hook.signers(signer.address)).to.equal(true) 143 | expect(await hook.owner()).to.equal(user.address) 144 | 145 | // Transfer ownership 146 | expect(hook.transferOwnership(anotherUser.address)) 147 | expect(await hook.owner()).to.equal(anotherUser.address) 148 | 149 | // Add a signer again from previous owner 150 | const anotherSigner = ethers.Wallet.createRandom() 151 | await expect(hook.addSigner(anotherSigner.address)).to.revertedWith( 152 | 'Ownable: caller is not the owner' 153 | ) 154 | expect(await hook.signers(anotherSigner.address)).to.equal(false) 155 | 156 | // Add a signer from new owner 157 | await hook.connect(anotherUser).addSigner(anotherSigner.address) 158 | expect(await hook.signers(anotherSigner.address)).to.equal(true) 159 | 160 | // Remove signer from new owner 161 | await hook.connect(anotherUser).removeSigner(signer.address) 162 | expect(await hook.signers(signer.address)).to.equal(false) 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /examples/solidity/hooks/guild-hook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "packageManager": "yarn@3.6.0", 4 | "workspaces": [ 5 | "examples/apps/**", 6 | "examples/solidity/**", 7 | "examples/paywall/*" 8 | ], 9 | "devDependencies": { 10 | "turbo": "^1.10.5" 11 | }, 12 | "scripts": { 13 | "build": "turbo build", 14 | "test": "turbo test", 15 | "lint": "turbo lint" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": [ 6 | "^build" 7 | ], 8 | "outputs": [ 9 | ".next/**", 10 | "!.next/cache/**", 11 | "dist/**", 12 | "build/**" 13 | ] 14 | }, 15 | "test": { 16 | "dependsOn": [ 17 | "build" 18 | ] 19 | }, 20 | "lint": {}, 21 | "dev": { 22 | "cache": false, 23 | "persistent": true 24 | } 25 | } 26 | } --------------------------------------------------------------------------------