Skip to content

Appv2 faucet #4189

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app2/app2.nix
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ _: {
../typescript-sdk
../ts-sdk
];
hash = "sha256-v5h5l2fwD01PlcrSzKWIk6swc3m6wqgjDzbti3DnAxI=";
hash = "sha256-2DkS7rIigf5qSfHW+cbMKhKN/noAG2oWb2GzXw3ibG0=";
buildInputs = deps;
nativeBuildInputs = buildInputs;
pnpmWorkspaces = [
Expand Down
1 change: 1 addition & 0 deletions app2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"jsdom": "^26.0.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"svelte-turnstile": "^0.10.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"typescript-eslint": "^8.24.1",
Expand Down
40 changes: 40 additions & 0 deletions app2/src/lib/queries/faucet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { graphql } from "gql.tada"
import { createQueryGraphql } from "$lib/utils/queries"
import { Schema } from "effect"
import { URLS } from "$lib/constants"

export const faucetUnoMutationDocument = graphql(`
mutation UnoFaucetMutation(
$chainId: String!,
$denom: String!,
$address: String!,
$captchaToken: String!
) {
drip_drop {
send(
chainId: $chainId,
denom: $denom,
address: $address,
captchaToken: $captchaToken
)
}
}
`)

export const faucetUnoMutation = ({
chainId,
denom,
address,
captchaToken
}: {
chainId: string
denom: string
address: string
captchaToken: string
}) =>
createQueryGraphql({
schema: Schema.Struct({ send: Schema.String }),
document: faucetUnoMutationDocument,
variables: { chainId, denom, address, captchaToken },
url: URLS().GRAPHQL
})
220 changes: 203 additions & 17 deletions app2/src/routes/faucet/Faucet.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,225 @@ import Button from "$lib/components/ui/Button.svelte"
import Card from "$lib/components/ui/Card.svelte"
import SharpWalletIcon from "$lib/components/icons/SharpWalletIcon.svelte"
import AddressComponent from "$lib/components/model/AddressComponent.svelte"
import { Option } from "effect"

import { Option, Effect, Data, pipe } from "effect"
import type { NoSuchElementException } from "effect/Cause"
import { wallets } from "$lib/stores/wallets.svelte.ts"
import { chains } from "$lib/stores/chains.svelte.ts"
import AngleArrowIcon from "$lib/components/icons/AngleArrowIcon.svelte"
import { Turnstile } from "svelte-turnstile"
import request from "graphql-request"
import { writable } from "svelte/store"
import { faucetUnoMutationDocument } from "$lib/queries/faucet"
import { URLS } from "$lib/constants"
import { bech32, bytes } from "@scure/base"
import { extractErrorDetails } from "@unionlabs/sdk/utils"

// Define the faucet state type using Data.TaggedEnum.
type FaucetProcessState = Data.TaggedEnum<{
Idle: {}
Verifying: {}
Verified: { token: string }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this mean?

Submitting: { token: string }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specify what the type of schema is of token by using one of the types from schema/

Success: { message: string }
Failure: { error: string }
}>

// Create the tagged enum instance.
const FaucetProcess = Data.taggedEnum<FaucetProcessState>()

// Initialize the faucet process state to Idle.
const faucetProcess = writable<FaucetProcessState>(FaucetProcess.Idle())

// Variables for managing the Turnstile component.
let resetTurnstile: () => void
let showTurnstile = false

// When the user clicks "Claim", trigger verification.
// For now, we bypass verification and use a dummy token.
const startVerification = () => {
faucetProcess.set(FaucetProcess.Verifying())
showTurnstile = true
resetTurnstile?.() // resets/retriggers the Turnstile if available
}

// Callback for successful Turnstile captcha.
const handleTurnstileCallback = (
e: CustomEvent<{ token: string; preClearanceObtained: boolean }>
) => {
const token = e.detail.token
faucetProcess.set(FaucetProcess.Verified({ token }))
// Immediately submit the faucet request.
submitFaucetRequest(token)
}

// Callback for a Turnstile error.
const handleTurnstileError = (e: CustomEvent<{ code: string }>) => {
faucetProcess.set(FaucetProcess.Failure({ error: `Verification error: ${e.detail.code}` }))
showTurnstile = false
}
Comment on lines +58 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only get a code and no other data? best to always propagate all error data all the way upwards, so that we get full context in the app if there is any error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


class Bech32DecodeError extends Data.TaggedError("Bech32DecodeError")<{
message: string
cause: unknown
}> {}

class FetchWalletError extends Data.TaggedError("FetchWalletError")<{
message: string
cause: unknown
}> {}

const faucetEffect = (
token: string
): Effect.Effect<
void,
Bech32DecodeError | Error | FetchWalletError | NoSuchElementException,
never
> =>
Effect.gen(function* () {
const addr = yield* wallets.cosmosAddress

const encodeAddr = Effect.try({
try: () => {
const hex = addr.slice(2)
const wordArray = bech32.toWords(bytes("hex", hex))
return bech32.encode("union", wordArray)
},
catch: cause =>
new Bech32DecodeError({
message: `Failed to prepare wallet address: ${cause}`,
cause: extractErrorDetails(cause as Error)
})
})

const fetchWallet = (addr: string) =>
Effect.tryPromise({
try: () =>
request(URLS().GRAPHQL, faucetUnoMutationDocument, {
chainId: "union-testnet-10",
denom: "muno",
address: addr,
captchaToken: token
}),
catch: cause =>
new FetchWalletError({
message: `graphql request failed to fetch wallet with addr ${addr}`,
cause: extractErrorDetails(cause)
})
})

const result = yield* pipe(encodeAddr, Effect.flatMap(fetchWallet))

if (!result.drip_drop || !result.drip_drop.send) {
return yield* Effect.fail(new Error("Empty faucet response"))
}
if (result.drip_drop.send.startsWith("ERROR")) {
return yield* Effect.fail(new Error(`Error from faucet: ${result.drip_drop.send}`))
}

yield* Effect.log("Faucet response:", result)

// Wrap the store update and other side effects in Effect.sync
return yield* Effect.sync(() => {
faucetProcess.set(FaucetProcess.Success({ message: result.drip_drop.send }))
showTurnstile = false
})
})

const submitFaucetRequest = (token: string) => {
// Update the state to "Submitting" before starting the effect
faucetProcess.set(FaucetProcess.Submitting({ token }))

Effect.runPromise(faucetEffect(token)).catch(error => {
console.info("Faucet error:", error)
faucetProcess.set(FaucetProcess.Failure({ error: `Faucet error: ${error}` }))
showTurnstile = false
})
}

// Reset the faucet process to allow a new request.
const resetProcess = () => {
faucetProcess.set(FaucetProcess.Idle())
showTurnstile = false
}
</script>

<Card divided class="self-center">
<div class="p-4 flex gap-1 ">
<div class="p-4 flex gap-1">
<h2>UNO Faucet</h2>
</div>
{#if Option.isSome(chains.data)}
{@const unionTestnet10 = Option.fromNullable(chains.data.value.find(c => c.universal_chain_id === "union.union-testnet-10"))} <div class="flex flex-col gap-4 p-4">
{@const unionTestnet10 = Option.fromNullable(
chains.data.value.find(
(c) => c.universal_chain_id === "union.union-testnet-10",
),
)}
<div class="flex flex-col gap-4 p-4">
<div>
<p>Official faucet for the UNO testnet token.</p>
<p>This faucet is protected by CloudFlare Turnstile.</p>
<p>You can use this faucet once a day.</p>
</div>
<div>
<div class="flex items-center mr-5 text-zinc-400 justify-self-end">
{#if Option.isSome(wallets.cosmosAddress) && Option.isSome(unionTestnet10)}
<p class="text-xs mb-2">
<AddressComponent truncate address={wallets.cosmosAddress.value} chain={unionTestnet10.value}/>
</p>
{:else}
<p class="text-xs mb-2"> No receiver</p>
<div class="flex items-center mr-5 text-zinc-400 justify-self-end">
{#if Option.isSome(wallets.cosmosAddress) && Option.isSome(unionTestnet10)}
<p class="text-xs mb-2">
<AddressComponent
truncate
address={wallets.cosmosAddress.value}
chain={unionTestnet10.value}
/>
</p>
{:else}
<p class="text-xs mb-2">No receiver</p>
{/if}
<AngleArrowIcon class="rotate-270" />
</div>
{#if $faucetProcess._tag === "Idle"}
<div class="flex gap-4">
<Button
onclick={startVerification}
class="flex-1"
disabled={!Option.isSome(wallets.cosmosAddress)}>Claim</Button
>
<Button><SharpWalletIcon class="size-5" /></Button>
</div>
{:else if $faucetProcess._tag === "Verifying"}
<div class="flex flex-col items-center">
<p class="text-xs">Verifying, please complete captcha...</p>
{#if showTurnstile}
<Turnstile
siteKey="0x4AAAAAAA-eVs5k0b8Q1dl5"
on:callback={handleTurnstileCallback}
on:error={handleTurnstileError}
theme="auto"
size="normal"
bind:reset={resetTurnstile}
/>
{/if}
</div>
{:else if $faucetProcess._tag === "Submitting"}
<div class="flex flex-col items-center">
<p class="text-xs">Submitting faucet request...</p>
</div>
{:else if $faucetProcess._tag === "Success"}
<div class="flex flex-col items-center">
<p class="text-xs">Tokens sent! Transaction hash:</p>
<p class="text-xs break-all">
<a
href={`https://explorer.testnet-10.union.build/union/tx/${$faucetProcess.message}`}
target="_blank"
>
{$faucetProcess.message}
</a>
</p>
<Button onclick={resetProcess} class="mt-2">New Request</Button>
</div>
{:else if $faucetProcess._tag === "Failure"}
<div class="flex flex-col items-center">
<p class="text-xs text-red-500">Error: {$faucetProcess.error}</p>
<Button onclick={resetProcess} class="mt-2">Retry</Button>
</div>
{/if}
<AngleArrowIcon class="rotate-270"/>
</div>
<div class="flex gap-4">
<Button class="flex-1">Claim</Button>
<Button><SharpWalletIcon class="size-5"/></Button>
</div>
</div>
</div>
{/if}
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading