Merge branch 'master' into refactor_payment_modular_tuts

This commit is contained in:
Dennis Dawson
2025-05-02 12:50:46 -07:00
committed by GitHub
257 changed files with 3519 additions and 1518 deletions

View File

@@ -0,0 +1,2 @@
ISSUER_ACCOUNT_SEED=<your-seed-goes-here>
PORT=3005

View File

@@ -0,0 +1,12 @@
# Credential Issuing Service - Python sample code
This code implements an HTTP API that issues credentials on the XRPL on request.
Quick install & usage:
```sh
npm install
node issuer_service.js
```
For more detail, see the full tutorial for [How to build a service that issues credentials on the XRP Ledger](https://xrpl.org/docs/tutorials/javascript/build-apps/credential-issuing-service).

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
import dotenv from "dotenv";
import inquirer from "inquirer";
import { Client, Wallet } from "xrpl";
import { lookUpCredentials } from "./look_up_credentials.js";
import { hexToString } from "@xrplf/isomorphic/dist/utils/index.js";
const XRPL_SERVER = "wss://s.devnet.rippletest.net:51233"
dotenv.config();
async function initWallet() {
let seed = process.env.SUBJECT_ACCOUNT_SEED;
if (!seed) {
const { seedInput } = await inquirer.prompt([
{
type: "password",
name: "seedInput",
message: "Subject account seed:",
validate: (input) => (input ? true : "Please specify the subject's master seed"),
},
]);
seed = seedInput;
}
return Wallet.fromSeed(seed);
}
async function main() {
const client = new Client(XRPL_SERVER);
await client.connect();
const wallet = await initWallet();
const pendingCredentials = await lookUpCredentials(
client,
"",
wallet.address,
"no"
);
const choices = pendingCredentials.map((cred, i) => ({
name: `${i+1}) '${hexToString(cred.CredentialType)}' issued by ${cred.Issuer}`,
value: i,
}));
choices.unshift({ name: "0) No, quit.", value: -1 });
const { selectedIndex } = await inquirer.prompt([
{
type: "list",
name: "selectedIndex",
message: "Accept a credential?",
choices,
},
]);
if (selectedIndex === -1) {
process.exit(0);
}
const chosenCred = pendingCredentials[selectedIndex];
const tx = {
TransactionType: "CredentialAccept",
Account: wallet.address,
CredentialType: chosenCred.CredentialType,
Issuer: chosenCred.Issuer,
};
console.log("Submitting transaction:", tx);
const response = await client.submit(tx, { autofill: true, wallet });
console.log("Response:", response);
await client.disconnect();
}
main().catch((err) => {
console.error("❌ Error:", err.message);
process.exit(1);
})

View File

@@ -0,0 +1,138 @@
import {
isoTimeToRippleTime,
rippleTimeToISOTime,
isValidClassicAddress,
} from "xrpl";
import { stringToHex, hexToString } from "@xrplf/isomorphic/dist/utils/index.js";
import { ValueError } from "./errors.js";
// Regex constants
const CREDENTIAL_REGEX = /^[A-Za-z0-9_.-]{1,128}$/;
const URI_REGEX = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
/**
* Validate credential request.
* This function performs parameter validation. Validated fields:
* - subject (required): the subject of the credential, as a classic address
* - credential (required): the credential type, in human-readable (ASCII) chars
* - uri (optional): URI of the credential in human-readable (ASCII) chars
* - expiration (optional): time when the credential expires (displayed as an ISO 8601 format string in JSON)
*/
export function validateCredentialRequest({ subject, credential, uri, expiration }) {
// Validate subject
if (typeof subject !== "string") {
throw new ValueError("Must provide a string 'subject' field");
}
if (!isValidClassicAddress(subject)) {
throw new ValueError(`subject not valid address: '${subject}'`);
}
// Validate credential
if (typeof credential !== "string") {
throw new ValueError("Must provide a string 'credential' field");
}
if (!CREDENTIAL_REGEX.test(credential)) {
/**
* Checks if the specified credential type is one that this service issues.
* XRPL credential types can be any binary data; this service issues
* any credential that can be encoded from the following ASCII chars:
* alphanumeric characters, underscore, period, and dash. (min length 1, max 128)
*
* You might want to further limit the credential types, depending on your
* use case; for example, you might only issue one specific credential type.
*/
throw new ValueError(`credential not allowed: '${credential}'.`);
}
/*
(Optional) Checks if the specified URI is acceptable for this service.
XRPL Credentials' URI values can be any binary data; this service
adds any user-requested URI to a Credential as long as the URI
can be encoded from the characters usually allowed in URIs, namely
the following ASCII chars:
alphanumeric characters (upper and lower case)
the following symbols: -._~:/?#[]@!$&'()*+,;=%
(minimum length 1 and max length 256 chars)
You might want to instead define your own URI and attach it to the
Credential regardless of user input, or you might want to verify that the
URI points to a valid Verifiable Credential document that matches the user.
*/
if (uri !== undefined) {
if (typeof uri !== "string" || !URI_REGEX.test(uri)) {
throw new ValueError(`URI isn't valid: ${uri}`);
}
}
// Validate and parse expiration
let parsedExpiration;
if (expiration !== undefined) {
if (typeof expiration !== "string") {
throw new ValueError(`Unsupported expiration format: ${typeof expiration}`);
}
parsedExpiration = new Date(expiration);
if (isNaN(parsedExpiration.getTime())) {
throw new ValueError(`Invalid expiration date: ${expiration}`);
}
}
return {
subject,
credential,
uri,
expiration: parsedExpiration,
};
}
// Convert an XRPL ledger entry into a usable credential object
export function credentialFromXrpl(entry) {
const { Subject, CredentialType, URI, Expiration, Flags } = entry;
return {
subject: Subject,
credential: hexToString(CredentialType),
uri: URI ? hexToString(URI) : undefined,
expiration: Expiration ? rippleTimeToISOTime(Expiration) : undefined,
accepted: Boolean(Flags & 0x00010000), // lsfAccepted
};
}
// Convert to an object in a format closer to the XRP Ledger representation
export function credentialToXrpl(cred) {
// Credential type and URI are hexadecimal;
// Expiration, if present, is in seconds since the Ripple Epoch.
return {
subject: cred.subject,
credential: stringToHex(cred.credential),
uri: cred.uri ? stringToHex(cred.uri) : undefined,
expiration: cred.expiration
? isoTimeToRippleTime(cred.expiration)
: undefined,
};
}
export function verifyDocuments({ documents }) {
/**
* This is where you would check the user's documents to see if you
* should issue the requested Credential to them.
* Depending on the type of credentials your service needs, you might
* need to implement different types of checks here.
*/
if (typeof documents !== "object" || Object.keys(documents).length === 0) {
throw new ValueError("you must provide a non-empty 'documents' field");
}
// As a placeholder, this example checks that the documents field
// contains a string field named "reason" containing the word "please".
const reason = documents.reason;
if (typeof reason !== "string") {
throw new ValueError("documents must contain a 'reason' string");
}
if (!reason.toLowerCase().includes("please")) {
throw new ValueError("reason must include 'please'");
}
}

View File

@@ -0,0 +1,17 @@
export class ValueError extends Error {
constructor(message) {
super(message);
this.name = "ValueError";
this.status = 400;
this.type = "badRequest";
}
}
export class XRPLTxError extends Error {
constructor(xrplResponse, status = 400) {
super("XRPL transaction failed");
this.name = "XRPLTxError";
this.status = status;
this.body = xrplResponse.result;
}
}

View File

@@ -0,0 +1,208 @@
import express from "express";
import morgan from 'morgan';
import inquirer from "inquirer";
import dotenv from "dotenv";
import { Wallet, Client } from "xrpl";
import {
validateCredentialRequest,
verifyDocuments,
credentialToXrpl,
credentialFromXrpl,
} from "./credential.js";
import { XRPLTxError } from "./errors.js";
import { lookUpCredentials } from "./look_up_credentials.js";
dotenv.config();
async function initWallet() {
let seed = process.env.ISSUER_ACCOUNT_SEED;
if (!seed || seed.startsWith("<")) {
const { seedInput } = await inquirer.prompt([
{
type: "password",
name: "seedInput",
message: "Issuer account seed:",
validate: (input) => (input ? true : "Please specify the issuer's master seed"),
},
]);
seed = seedInput;
}
return Wallet.fromSeed(seed);
}
// Error handling --------------------------------------------------------------
function handleAppError(res, err) {
if (err.name === "ValueError") {
return res.status(err.status).json({
error: err.type,
error_message: err.message,
});
}
if (err.name === "XRPLTxError") {
return res.status(err.status).json(err.body);
}
// Default fallback
return res.status(400).json({ error_message: err.message });
}
async function main() {
// Set up XRPL connection ------------------------------------------------------
const client = new Client("wss://s.devnet.rippletest.net:51233");
await client.connect();
const wallet = await initWallet();
console.log("✅ Starting credential issuer with XRPL address", wallet.address);
// Define Express app ------------------------------------------------------
const app = express();
app.use(morgan('common')); // Logger
app.use(express.json()); // Middleware to parse JSON requests
// POST /credential - Method for users to request a credential from the service -------------------
app.post("/credential", async (req, res) => {
try {
// validateCredentialRequest() throws if the request is not validly formatted
const credRequest = validateCredentialRequest(req.body);
/**
* As a credential issuer, you typically need to verify some information
* about someone before you issue them a credential. For this example,
* the user passes relevant information in a documents field of the API request.
* The documents are kept confidential, off-chain.
*
* verifyDocuments() throws if the provided documents don't pass inspection
*/
verifyDocuments(req.body);
const credXrpl = credentialToXrpl(credRequest);
const tx = {
TransactionType: "CredentialCreate",
Account: wallet.address,
Subject: credXrpl.subject,
CredentialType: credXrpl.credential,
URI: credXrpl.uri,
Expiration: credXrpl.expiration,
};
const ccResponse = await client.submitAndWait(tx, { autofill: true, wallet });
if (ccResponse.result.meta.TransactionResult === "tecDUPLICATE") {
throw new XRPLTxError(ccResponse, 409);
} else if (ccResponse.result.meta.TransactionResult !== "tesSUCCESS") {
throw new XRPLTxError(ccResponse);
}
return res.status(201).json(ccResponse.result);
} catch (err) {
return handleAppError(res, err);
}
});
// GET /admin/credential - Method for admins to look up all credentials issued -------------------
app.get("/admin/credential", async (req, res) => {
try {
// ?accepted=yes|no|both query parameter - the default is "both"
const query = Object.fromEntries(
Object.entries(req.query).map(([k, v]) => [k.toLowerCase(), v])
);
const filterAccepted = (query.accepted || "both").toLowerCase();
const credentials = await lookUpCredentials(client, wallet.address, "", filterAccepted);
const result = credentials.map((entry) => credentialFromXrpl(entry));
return res.status(200).json({ credentials: result });
} catch (err) {
return handleAppError(res, err);
}
});
// DELETE /admin/credential - Method for admins to revoke an issued credential ----------------------------
app.delete("/admin/credential", async (req, res) => {
let delRequest;
try {
delRequest = validateCredentialRequest(req.body);
const { credential } = credentialToXrpl(delRequest);
// To save on transaction fees, check if the credential exists on ledger before attempting to delete it.
// If the credential is not found, a RippledError (`entryNotFound`) is thrown.
await client.request({
command: "ledger_entry",
credential: {
subject: delRequest.subject,
issuer: wallet.address,
credential_type: credential,
},
ledger_index: "validated",
});
const tx = {
TransactionType: "CredentialDelete",
Account: wallet.address,
Subject: delRequest.subject,
CredentialType: credential,
};
const cdResponse = await client.submitAndWait(tx, { autofill: true, wallet });
if (cdResponse.result.meta.TransactionResult === "tecNO_ENTRY") {
// Usually this won't happen since we just checked for the credential,
// but it's possible it got deleted since then.
throw new XRPLTxError(cdResponse, 404);
} else if (cdResponse.result.meta.TransactionResult !== "tesSUCCESS") {
throw new XRPLTxError(cdResponse);
}
return res.status(200).json(cdResponse.result);
} catch (err) {
if (err.data?.error === "entryNotFound") {
return res.status(404).json({
error: err.data.error,
error_message: `Credential doesn't exist for subject '${delRequest.subject}' and credential type '${delRequest.credential}'`,
});
} else {
return handleAppError(res, err);
}
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🔐 Credential issuer service running on port: ${PORT}`);
});
}
// Start the server --------------------------------------------------------------
main().catch((err) => {
console.error("❌ Fatal startup error:", err);
process.exit(1);
});
/**
* Tip: Express returns plain text by default for unhandled routes (like 404 Not Found).
* To ensure consistent JSON responses across your API, consider adding a catch-all
* handler.
*
* Example:
*
* // Handle unmatched routes
* app.use((req, res) => {
* res.status(404).json({
* error: "notFound",
* error_message: `Route not found: ${req.method} ${req.originalUrl}`,
* });
* });
*
* You can also add a global error handler for unhandled exceptions:
*
* app.use((err, req, res, next) => {
* console.error("Unhandled error:", err);
* res.status(500).json({
* error: "internalServerError",
* error_message: "Something went wrong.",
* });
* });
*/

View File

@@ -0,0 +1,53 @@
import { ValueError } from "./errors.js";
const lsfAccepted = 0x00010000;
/**
* Looks up Credentials issued by/to a specified XRPL account, optionally
* filtering by accepted status. Handles pagination.
*/
export async function lookUpCredentials(client, issuer, subject, accepted = "both") {
const account = issuer || subject; // Use whichever is specified, issuer if both
if (!account) {
throw new ValueError("Must specify issuer or subject");
}
accepted = accepted.toLowerCase();
if (!["yes", "no", "both"].includes(accepted)) {
throw new ValueError("accepted must be 'yes', 'no', or 'both'");
}
const credentials = [];
let request = {
command: "account_objects",
account,
type: "credential",
ledger_index: "validated",
};
// Fetch first page
let response = await client.request(request);
while (true) {
for (const obj of response.result.account_objects) {
if (issuer && obj.Issuer !== issuer) continue;
if (subject && obj.Subject !== subject) continue;
const credAccepted = Boolean(obj.Flags & lsfAccepted);
if (accepted === "yes" && !credAccepted) continue;
if (accepted === "no" && credAccepted) continue;
credentials.push(obj);
}
if (!response.result.marker) break;
/**
* If there is a marker, request the next page using the convenience function "requestNextPage()".
* See https://js.xrpl.org/classes/Client.html#requestnextpage to learn more.
**/
response = await client.requestNextPage(request, response.result);
}
return credentials;
}

View File

@@ -0,0 +1,21 @@
{
"name": "issuer_service",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@xrplf/isomorphic": "^1.0.1",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"inquirer": "^12.5.2",
"morgan": "^1.10.0",
"xrpl": "^4.2.0"
}
}