diff --git a/content/dev-tools/faucets.json b/content/dev-tools/faucets.json
new file mode 100644
index 00000000000..52dabc5fff3
--- /dev/null
+++ b/content/dev-tools/faucets.json
@@ -0,0 +1,28 @@
+{
+ "knownFaucets": [
+ {
+ "id": "faucet-select-testnet",
+ "wsUrl": "wss://s.altnet.rippletest.net:51233/",
+ "jsonRpcUrl": "https://s.altnet.rippletest.net:51234/",
+ "faucetUrl": "faucet.altnet.rippletest.net",
+ "shortName": "Testnet",
+ "desc": "Mainnet-like network for testing applications."
+ },
+ {
+ "id": "faucet-select-devnet",
+ "wsUrl": "wss://s.devnet.rippletest.net:51233/",
+ "jsonRpcUrl": "https://s.devnet.rippletest.net:51234/",
+ "faucetUrl": "faucet.devnet.rippletest.net",
+ "shortName": "Devnet",
+ "desc": "Preview of upcoming amendments."
+ },
+ {
+ "id": "faucet-select-xahau",
+ "wsUrl": "wss://xahau-test.net/",
+ "jsonRpcUrl": "https://xahau-test.net/",
+ "faucetUrl": "xahau-test.net",
+ "shortName": "Xahau-Testnet",
+ "desc": "Hooks (L1 smart contracts) enabled Xahau testnet."
+ }
+ ]
+}
diff --git a/content/dev-tools/xrp-faucets.page.tsx b/content/dev-tools/xrp-faucets.page.tsx
new file mode 100644
index 00000000000..27a812b17eb
--- /dev/null
+++ b/content/dev-tools/xrp-faucets.page.tsx
@@ -0,0 +1,203 @@
+import * as React from 'react';
+import { useTranslate } from '@portal/hooks';
+import { useState } from 'react';
+import { Client, dropsToXrp, Wallet } from 'xrpl';
+import * as faucetData from './faucets.json'
+import XRPLoader from 'content/static/components/XRPLoader';
+
+interface FaucetInfo {
+ id: string,
+ wsUrl: string,
+ jsonRpcUrl: string,
+ faucetUrl: string,
+ shortName: string,
+ desc: string,
+}
+
+async function waitForSequence(client: Client, address: string):
+ Promise<{ sequence: string, balance: string }>
+ {
+ let response;
+ while (true) {
+ try {
+ response = await client.request({
+ command: "account_info",
+ account: address,
+ ledger_index: "validated"
+ })
+ break
+ } catch(e) {
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ }
+ }
+ console.log(response)
+
+ return { sequence: response.result.account_data.Sequence, balance: response.result.account_data.Balance}
+}
+
+function FaucetEndpoints({ faucet, givenKey } : { faucet: FaucetInfo, givenKey: string}) {
+ const { translate } = useTranslate();
+
+ return (
+
{translate(`${faucet.shortName} Servers`)}
+
+
+ // WebSocket
+ {faucet.wsUrl}
+
+ // JSON-RPC
+ {faucet.jsonRpcUrl}
+
+
+
)
+}
+
+function FaucetSidebar({ faucets }: { faucets: FaucetInfo[] }): React.JSX.Element {
+ return (
+ {faucets.map(
+ (faucet) =>
+ )}
+ )
+}
+
+export default function XRPFaucets(): React.JSX.Element {
+ const { translate } = useTranslate();
+
+ const faucets: FaucetInfo[] = faucetData.knownFaucets
+
+ const [selectedFaucet, setSelectedFaucet] = useState(faucets[0])
+
+ return (
+
+
+
+
+
+ {translate("XRP Faucets")}
+
+
{translate("These ")}{translate("parallel XRP Ledger test networks")} {translate("provide platforms for testing changes to the XRP Ledger and software built on it, without using real funds.")}
+
{translate("These funds are intended for")} {translate("testing")} {translate("only. Test networks' ledger history and balances are reset as necessary. Devnets may be reset without warning.")}
+
{translate("All balances and XRP on these networks are separate from Mainnet. As a precaution, do not use the Testnet or Devnet credentials on the Mainnet.")}
+
+
{translate("Choose Network:")}
+ { faucets.map((net) => (
+
+ setSelectedFaucet(net)} className="form-check-input" type="radio"
+ name="faucet-selector" id={net.id} checked={selectedFaucet.shortName == net.shortName} />
+
+ {translate(net.shortName)} : {translate(net.desc)}
+
+
+ )) }
+
+
+
+
+
+
+
+
+ )
+}
+
+async function generateFaucetCredentialsAndUpdateUI(
+ selectedFaucet: FaucetInfo,
+ setButtonClicked: React.Dispatch>,
+ setGeneratedCredentialsFaucet: React.Dispatch>,
+ setAddress: React.Dispatch>,
+ setSecret: React.Dispatch>,
+ setBalance: React.Dispatch>,
+ setSequence: React.Dispatch>): Promise {
+
+ setButtonClicked(true)
+
+ // Clear existing credentials
+ setGeneratedCredentialsFaucet(selectedFaucet.shortName)
+ setAddress("")
+ setSecret("")
+ setBalance("")
+ setSequence("")
+ const { translate } = useTranslate();
+
+
+ const wallet = Wallet.generate()
+
+ const client = new Client(selectedFaucet.wsUrl)
+ await client.connect()
+
+ try {
+ setAddress(wallet.address)
+ setSecret(wallet.seed)
+
+ await client.fundWallet(wallet, { faucetHost: selectedFaucet.faucetUrl, usageContext: "xrpl.org-faucet" })
+
+ const response = await waitForSequence(client, wallet.address)
+
+ setSequence(response.sequence)
+ setBalance(response.balance)
+
+ } catch (e) {
+ alert(translate(`There was an error with the ${selectedFaucet.shortName} faucet. Please try again.`))
+ }
+ setButtonClicked(false)
+}
+
+function TestCredentials({selectedFaucet}) {
+ const { translate } = useTranslate();
+
+ const [generatedCredentialsFaucet, setGeneratedCredentialsFaucet] = useState("")
+ const [address, setAddress] = useState("")
+ const [secret, setSecret] = useState("")
+ const [balance, setBalance] = useState("")
+ const [sequence, setSequence] = useState("")
+ const [buttonClicked, setButtonClicked] = useState(false)
+
+ return (
+ {/*
TODO: Re-add this once we find a good way to avoid browser/server mismatch errors */}
+
+ generateFaucetCredentialsAndUpdateUI(
+ selectedFaucet,
+ setButtonClicked,
+ setGeneratedCredentialsFaucet,
+ setAddress,
+ setSecret,
+ setBalance,
+ setSequence)
+ } className="btn btn-primary mr-2 mb-2">
+ {translate(`Generate ${selectedFaucet.shortName} credentials`)}
+
+
+ {/* */}
+
+
+ {generatedCredentialsFaucet &&
+
{translate(`Your ${generatedCredentialsFaucet} Credentials`)}
+ }
+
+ {(buttonClicked && address === "") &&
}
+
+ {address &&
{translate("Address")} {address}}
+
+ {secret &&
{translate("Secret")} {secret}}
+
+ {(address && !balance) && (
+
+
+
)}
+
+ {balance &&
+
{translate("Balance")}
+ {dropsToXrp(balance).toLocaleString("en")} {translate("XRP")}
+ }
+
+ {sequence &&
+
{translate("Sequence Number")}
+ {sequence}
+ }
+
+ {(secret && !sequence) &&
}
+
+
+ )
+}
diff --git a/content/sidebars.yaml b/content/sidebars.yaml
index b6b96513274..19ea5d3edeb 100644
--- a/content/sidebars.yaml
+++ b/content/sidebars.yaml
@@ -666,6 +666,8 @@
- label: xrp-ledger.toml Checker
- label: Domain Verification Checker
- label: XRP Faucets
+ href: /dev-tools/xrp-faucets
+ page: /dev-tools/xrp-faucets.page.tsx
- label: Transaction Sender
- label: XRPL Learning Portal
href: https://learn.xrpl.org/
diff --git a/content/static/components/XRPLoader.tsx b/content/static/components/XRPLoader.tsx
new file mode 100644
index 00000000000..277be38409a
--- /dev/null
+++ b/content/static/components/XRPLoader.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+
+export interface XRPLoaderProps {
+ message?: string
+}
+
+export default function XRPLoader(props: XRPLoaderProps) {
+ return (
+
+
+ {props.message}
+
);
+}
diff --git a/content/static/js/type-helpers.ts b/content/static/js/type-helpers.ts
new file mode 100644
index 00000000000..5feb867b4c1
--- /dev/null
+++ b/content/static/js/type-helpers.ts
@@ -0,0 +1,27 @@
+type TypeofType =
+ | 'bigint'
+ | 'boolean'
+ | 'function'
+ | 'number'
+ | 'object'
+ | 'string'
+ | 'undefined'
+
+type TypeCheckFn = (thing: unknown) => boolean
+
+
+/**
+ * Curried function for creating typeof checker functions.
+ * @param {string} type The type to check against (eg 'string', 'number')
+ * @param {function} [secondaryTest] Optional additional test function to run in cases where a type match isn't always a sure indicator.
+ * @returns {boolean} Whether the value matches the type
+ */
+const isTypeof =
+ (type: TypeofType, secondaryTest?: TypeCheckFn) =>
+ (thing: unknown): thing is T => {
+ const matches = typeof thing === type
+ if (matches && secondaryTest) return secondaryTest(thing)
+ return matches
+ }
+
+export const isFunction = isTypeof<(...args: unknown[]) => unknown>('function')
diff --git a/content/static/js/xrpl-guard.tsx b/content/static/js/xrpl-guard.tsx
new file mode 100644
index 00000000000..c71e1022787
--- /dev/null
+++ b/content/static/js/xrpl-guard.tsx
@@ -0,0 +1,85 @@
+import { useTranslate } from '@portal/hooks';
+import { isFunction } from './type-helpers'
+import { FC } from 'react'
+import { useEffect, useState } from 'react'
+import React = require('react');
+import XRPLoader from '../components/XRPLoader';
+
+export const MIN_LOADER_MS = 1250
+export const DEFAULT_TIMEOUT = 1000
+
+const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay))
+
+/**
+ * Evaluate a check function will eventually resolve to `true`
+ *
+ * If check is initially true, immediatly return `isTrue`
+ * If check is initially false and becomes true, return true after `timeoutMs`
+ */
+export const useThrottledCheck = (
+ check: () => boolean,
+ timeoutMs = DEFAULT_TIMEOUT,
+) => {
+ const [isTrue, setIsTrue] = useState(() => check())
+
+ useEffect(() => {
+ const doCheck = async (tries = 0) => {
+ const waitMs = 250,
+ waitedMs = tries * waitMs
+
+ if (check()) {
+ const debouncedDelay =
+ waitedMs < timeoutMs ? timeoutMs - (waitedMs % timeoutMs) : 0
+
+ setTimeout(() => setIsTrue(true), debouncedDelay)
+ return
+ }
+
+ await sleep(waitMs)
+
+ doCheck(tries + 1)
+ }
+
+ if (!isTrue) {
+ doCheck()
+ }
+ }, [check, isTrue])
+
+ return isTrue
+}
+
+/**
+ * Show a loading spinner if XRPL isn't loaded yet by
+ * waiting at least MIN_LOADER_MS before rendering children
+ * in order to make the visual loading transition smooth
+ *
+ * e.g. if xrpl loads after 500ms, wait
+ * another MIN_LOADER_MS - 500ms before rendering children
+ *
+ * @param {function} testCheck for testing only, a check function to use
+ */
+export const XRPLGuard: FC<{ testCheck?: () => boolean, children }> = ({
+ testCheck,
+ children,
+}) => {
+
+ const { translate } = useTranslate();
+ const isXRPLLoaded = useThrottledCheck(
+ // @ts-expect-error - xrpl is added via a script tag (TODO: Directly import when xrpl.js 3.0 is released)
+ testCheck ?? (() => typeof xrpl === 'object'),
+ MIN_LOADER_MS,
+ )
+
+ return (
+ <>
+ {isXRPLLoaded ? (
+ isFunction(children) ? (
+ children()
+ ) : (
+ children
+ )
+ ) :
+ }
+ >
+ )
+}
diff --git a/content/top-nav.yaml b/content/top-nav.yaml
index 69f53080bb7..88ae979b501 100644
--- a/content/top-nav.yaml
+++ b/content/top-nav.yaml
@@ -67,7 +67,8 @@
- label: Send XRP
href: /tutorials/send-xrp/
- label: XRP Faucets
- href: /docs/dev-tools/xrp-faucets/
+ href: /dev-tools/xrp-faucets
+ page: /dev-tools/xrp-faucets.page.tsx
- label: XRPL Servers
href: /infrastructure/xrpl-servers/
- label: Dev Tools
diff --git a/package.json b/package.json
index 25a42076848..efcc81c977d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"dependencies": {
"@redocly/portal": "0.57.0",
"lottie-react": "^2.4.0",
- "moment": "^2.29.4"
+ "moment": "^2.29.4",
+ "xrpl": "^3.0.0-beta.1"
},
"overrides": {
"react": "^17.0.2"
diff --git a/tsconfig.json b/tsconfig.json
index b741d31f798..bbb3ea54103 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
"compilerOptions": {
"baseUrl": ".",
"jsx": "react",
+ "resolveJsonModule": true,
"paths": {
"@theme/*": [
"./@theme/*",