diff --git a/modules/ecash-lib/src/index.ts b/modules/ecash-lib/src/index.ts
--- a/modules/ecash-lib/src/index.ts
+++ b/modules/ecash-lib/src/index.ts
@@ -27,3 +27,5 @@
 export * from './token/common.js';
 export * from './token/empp.js';
 export * from './token/slp.js';
+
+export * as payment from './payment';
diff --git a/modules/ecash-lib/src/payment/asn1.test.ts b/modules/ecash-lib/src/payment/asn1.test.ts
new file mode 100644
--- /dev/null
+++ b/modules/ecash-lib/src/payment/asn1.test.ts
@@ -0,0 +1,134 @@
+// Copyright (c) 2025 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import { expect } from 'chai';
+import * as tls from 'node:tls';
+
+import { asn1, x509 } from '.';
+import { Ecc } from '../ecc.js';
+import { toHex } from '../io/hex.js';
+import { sha256 } from '../hash.js';
+import '../initNodeJs.js';
+
+/*
+key.pem:
+openssl ecparam -genkey -out key.pem -name secp256k1
+
+-----BEGIN EC PARAMETERS-----
+BgUrgQQACg==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MHQCAQEEIFzOgjk70LwSfTAWNAzwm2tZedMM30pwyMsaK8KkJAEHoAcGBSuBBAAK
+oUQDQgAE23jFaVYo7rulgXbZ5WaADFaKCzn8Lk6ZubOqabsXrqfM3JaKRrrPwyrk
+aXzvoFu5D28kF7OXDsomtTWH9h5Slg==
+-----END EC PRIVATE KEY-----
+
+cert.pem:
+openssl req -x509 -new \
+            -key key.pem \
+            -out cert.pem \
+            -sha256 \
+            -days 36500 \
+            -nodes \
+            -subj "/C=CA/ST=British Columbia/L=Vancouver/O=eCash Palace/OU=Software/CN=example.e.cash/emailAddress=example@e.cash"
+*/
+const CERT_PEM = `
+-----BEGIN CERTIFICATE-----
+MIICkTCCAjigAwIBAgIUTWHooPqrdMM/kO1CNMYN7m3P6CswCgYIKoZIzj0EAwIw
+gZ4xCzAJBgNVBAYTAkNBMRkwFwYDVQQIDBBCcml0aXNoIENvbHVtYmlhMRIwEAYD
+VQQHDAlWYW5jb3V2ZXIxFTATBgNVBAoMDGVDYXNoIFBhbGFjZTERMA8GA1UECwwI
+U29mdHdhcmUxFzAVBgNVBAMMDmV4YW1wbGUuZS5jYXNoMR0wGwYJKoZIhvcNAQkB
+Fg5leGFtcGxlQGUuY2FzaDAgFw0yNTAzMDIyMDMyNDNaGA8yMTI1MDIwNjIwMzI0
+M1owgZ4xCzAJBgNVBAYTAkNBMRkwFwYDVQQIDBBCcml0aXNoIENvbHVtYmlhMRIw
+EAYDVQQHDAlWYW5jb3V2ZXIxFTATBgNVBAoMDGVDYXNoIFBhbGFjZTERMA8GA1UE
+CwwIU29mdHdhcmUxFzAVBgNVBAMMDmV4YW1wbGUuZS5jYXNoMR0wGwYJKoZIhvcN
+AQkBFg5leGFtcGxlQGUuY2FzaDBWMBAGByqGSM49AgEGBSuBBAAKA0IABNt4xWlW
+KO67pYF22eVmgAxWigs5/C5Ombmzqmm7F66nzNyWika6z8Mq5Gl876BbuQ9vJBez
+lw7KJrU1h/YeUpajUzBRMB0GA1UdDgQWBBQKzjEN/xneEG0oc/OgW/w7/imk4TAf
+BgNVHSMEGDAWgBQKzjEN/xneEG0oc/OgW/w7/imk4TAPBgNVHRMBAf8EBTADAQH/
+MAoGCCqGSM49BAMCA0cAMEQCIDOPA89LS1r0/3nm8i4hT/VhHHEYPh3Mf42COhRP
+bpoDAiA2Mf29zeOXOxZ7B021KAJUpsSFtMEvBnllMQjQqsoaog==
+-----END CERTIFICATE-----
+`;
+
+describe('ASN1', () => {
+    const ecc = new Ecc();
+    it('asn1.parseCertPem self-signed secp256k1', () => {
+        const cert = asn1.parseCertPem(CERT_PEM);
+
+        expect(cert.sigAlg).to.deep.equal({
+            alg: x509.OID_ECDSA_WITH_SHA256,
+            params: undefined,
+        });
+        const subject = [
+            {
+                oid: x509.OID_COUNTRY_NAME,
+                value: 'CA',
+            },
+            {
+                oid: x509.OID_STATE_OR_PROVINCE_NAME,
+                value: 'British Columbia',
+            },
+            {
+                oid: x509.OID_LOCALITY_NAME,
+                value: 'Vancouver',
+            },
+            {
+                oid: x509.OID_ORGANIZATION_NAME,
+                value: 'eCash Palace',
+            },
+            {
+                oid: x509.OID_ORGANIZATIONAL_UNIT_NAME,
+                value: 'Software',
+            },
+            {
+                oid: x509.OID_COMMON_NAME,
+                value: 'example.e.cash',
+            },
+            {
+                oid: x509.OID_EMAIL_ADDRESS,
+                value: 'example@e.cash',
+            },
+        ];
+
+        expect(cert.tbs.version).to.equal(2);
+        expect(cert.tbs.sigAlg).to.deep.equal({
+            alg: x509.OID_ECDSA_WITH_SHA256,
+            params: undefined,
+        });
+        expect(toHex(cert.tbs.serial)).to.equal(
+            '4d61e8a0faab74c33f90ed4234c60dee6dcfe82b',
+        );
+        expect(cert.tbs.issuer).to.deep.equal(subject);
+        expect(cert.tbs.validity).to.deep.equal({
+            notBefore: 1740947563,
+            notAfter: 4894547563,
+        });
+        expect(cert.tbs.subject).to.deep.equal(subject);
+        expect(cert.tbs.pubkey.alg).to.deep.equal({
+            alg: x509.OID_EC_PUBLIC_KEY,
+            params: x509.OID_ANSIP256K1,
+        });
+        expect(toHex(cert.tbs.pubkey.data)).to.equal(
+            '04db78c5695628eebba58176d9e566800c568a0b39fc2e4e99b9b3aa69bb17aea7ccdc968a46bacfc32ae4697cefa05bb90f6f2417b3970eca26b53587f61e5296',
+        );
+
+        // Self-signed certificate is valid
+        ecc.ecdsaVerify(
+            cert.sig,
+            sha256(cert.tbs.raw),
+            ecc.compressPk(cert.tbs.pubkey.data),
+        );
+    });
+
+    it('asn1.parseCertPem built-in root certs', () => {
+        // Try parsing all built in root certificates
+        for (const certPem of tls.rootCertificates) {
+            // Throws an error if one cert failed
+            const cert = asn1.parseCertPem(certPem);
+            // Cert has at least one entry in subject
+            expect(cert.tbs.subject.length).to.be.greaterThan(0);
+        }
+    });
+});
diff --git a/modules/ecash-lib/src/payment/asn1.ts b/modules/ecash-lib/src/payment/asn1.ts
new file mode 100644
--- /dev/null
+++ b/modules/ecash-lib/src/payment/asn1.ts
@@ -0,0 +1,443 @@
+// Copyright (c) 2025 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import { bytesToStr } from '../io/str.js';
+import { Bytes } from '../io/bytes.js';
+
+interface Tag {
+    type: number;
+    isPrimitive: boolean;
+    size: number;
+}
+
+/** Identifier of a signature algorithm */
+export interface AlgIdent {
+    /** OID of the algorithm */
+    alg: string;
+    /** OID of the params */
+    params: string | undefined;
+}
+
+/** Entry of an issuer/subject */
+export interface Entry {
+    /** OID of the entry */
+    oid: string;
+    /** Value of the entry */
+    value: string;
+}
+
+/** Time range of the validity of the certificate */
+export interface Validity {
+    /** Certificate is not valid before this UNIX timestamp */
+    notBefore: number;
+    /** Certificate is not valid after this UNIX timestamp */
+    notAfter: number;
+}
+
+/** Public key of a certificate */
+export interface PubKey {
+    /** Signature algorithm of the public key */
+    alg: AlgIdent;
+    /** Bytes of the public key */
+    data: Uint8Array;
+}
+
+/** To-be-signed data of the certificate */
+export interface TBS {
+    /** Version of the certificate data */
+    version: number | undefined;
+    /** Serial number of the certificate */
+    serial: Uint8Array;
+    /** Signature algorithm used by the certificate */
+    sigAlg: AlgIdent;
+    /** Fields of the issuer of the certificate */
+    issuer: Entry[];
+    /** Time range the certificate is valid */
+    validity: Validity;
+    /** Fields of the subject of the certificate */
+    subject: Entry[];
+    /** Public key of the certificate */
+    pubkey: PubKey;
+    /** Raw bytes of the data to be signed */
+    raw: Uint8Array;
+}
+
+export interface Cert {
+    /** Data to be signed by the signature */
+    tbs: TBS;
+    /** Algorithm used for the signature */
+    sigAlg: AlgIdent;
+    /** Signature signing `tbs` */
+    sig: Uint8Array;
+    /** Raw bytes of the certificate */
+    raw: Uint8Array;
+}
+
+const TAG_VERSION = 0x00;
+const TAG_INT = 0x02;
+const TAG_BITSTR = 0x03;
+const TAG_OCTSTR = 0x04;
+const TAG_NULL = 0x05;
+const TAG_OID = 0x06;
+const TAG_UTF8STR = 0x0c;
+const TAG_SEQ = 0x10;
+const TAG_SET = 0x11;
+const TAG_NUMSTR = 0x12;
+const TAG_PRINSTR = 0x13;
+const TAG_T61STR = 0x14;
+const TAG_VIDEOSTR = 0x15;
+const TAG_IA5STR = 0x16;
+const TAG_UTCTIME = 0x17;
+const TAG_GENTIME = 0x18;
+const TAG_GRAPHSTR = 0x19;
+const TAG_ISO646STR = 0x1a;
+const TAG_GENSTR = 0x1b;
+const TAG_UNISTR = 0x1c;
+const TAG_CHARSTR = 0x1d;
+const TAG_BMPSTR = 0x1e;
+
+const REGEX_PEM_CERT =
+    /-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----/s;
+const REGEX_DIGITS = /\d+/;
+
+function readSize(bytes: Bytes, isPrimitive: boolean): number {
+    let size = bytes.readU8();
+
+    // Indefinite form
+    if (!isPrimitive && size === 0x80) {
+        throw new Error('Indefinite size.');
+    }
+
+    // Definite form
+    if ((size & 0x80) === 0) {
+        // Short form
+        return size;
+    }
+
+    // Long form
+    const numBytes = size & 0x7f;
+
+    if (numBytes > 3) {
+        throw new Error('Length octet is too long.');
+    }
+
+    size = 0;
+    for (let i = 0; i < numBytes; i++) {
+        size <<= 8;
+        size |= bytes.readU8();
+    }
+
+    return size;
+}
+
+function readTag(bytes: Bytes): Tag {
+    let type = bytes.readU8();
+    const isPrimitive = (type & 0x20) === 0;
+
+    if ((type & 0x1f) === 0x1f) {
+        let oct = type;
+        type = 0;
+        while ((oct & 0x80) === 0x80) {
+            oct = bytes.readU8();
+            type <<= 7;
+            type |= oct & 0x7f;
+        }
+    } else {
+        type &= 0x1f;
+    }
+
+    return {
+        type,
+        isPrimitive,
+        size: readSize(bytes, isPrimitive),
+    };
+}
+
+function readSeq(bytes: Bytes): Uint8Array {
+    const tag = readTag(bytes);
+    if (tag.type !== TAG_SEQ) {
+        throw new Error(
+            `Expected sequence type ${TAG_SEQ}, but got ${tag.type}`,
+        );
+    }
+    return bytes.readBytes(tag.size);
+}
+
+function alignBitstr(data: Uint8Array): Uint8Array {
+    const padding = data[0];
+    const bits = (data.length - 1) * 8 - padding;
+    const buf = data.slice(1);
+    const shift = 8 - (bits % 8);
+
+    if (shift === 8 || buf.length === 0) {
+        return buf;
+    }
+
+    const out = Buffer.allocUnsafe(buf.length);
+    out[0] = buf[0] >>> shift;
+
+    for (let i = 1; i < buf.length; i++) {
+        out[i] = buf[i - 1] << (8 - shift);
+        out[i] |= buf[i] >>> shift;
+    }
+
+    return out;
+}
+
+function readBitstr(bytes: Bytes): Uint8Array {
+    const tag = readTag(bytes);
+    if (tag.type !== TAG_BITSTR) {
+        throw new Error(
+            `Expected sequence type ${TAG_BITSTR}, but got ${tag.type}`,
+        );
+    }
+    return alignBitstr(bytes.readBytes(tag.size));
+}
+
+function readString(bytes: Bytes): Uint8Array {
+    const tag = readTag(bytes);
+    switch (tag.type) {
+        case TAG_BITSTR: {
+            return alignBitstr(bytes.readBytes(tag.size));
+        }
+        case TAG_OCTSTR:
+        case TAG_NUMSTR:
+        case TAG_PRINSTR:
+        case TAG_T61STR:
+        case TAG_VIDEOSTR:
+        case TAG_IA5STR:
+        case TAG_GRAPHSTR:
+        case TAG_UTF8STR:
+        case TAG_ISO646STR:
+        case TAG_GENSTR:
+        case TAG_UNISTR:
+        case TAG_CHARSTR:
+        case TAG_BMPSTR: {
+            return bytes.readBytes(tag.size);
+        }
+        default: {
+            throw new Error(`Expected string tag, got ${tag.type}`);
+        }
+    }
+}
+
+function readInt(bytes: Bytes): Uint8Array {
+    const tag = readTag(bytes);
+    if (tag.type !== TAG_INT) {
+        throw new Error(
+            `Expected integer type ${TAG_INT}, but got ${tag.type}`,
+        );
+    }
+    return bytes.readBytes(tag.size);
+}
+
+function bytesToBE(bytes: Uint8Array): number {
+    let num = 0;
+    for (const b of bytes) {
+        num <<= 8;
+        num |= b;
+    }
+    return num;
+}
+
+function readVersion(bytes: Bytes): number | undefined {
+    const startIdx = bytes.idx;
+    const tag = readTag(bytes);
+    if (tag.type != TAG_VERSION) {
+        bytes.idx = startIdx;
+        return undefined;
+    }
+    return bytesToBE(readInt(bytes));
+}
+
+function readAlgIdent(bytes: Bytes): AlgIdent {
+    let params: string | undefined = undefined;
+    bytes = new Bytes(readSeq(bytes));
+    const alg = readOID(bytes);
+    if (alg === undefined) {
+        throw new Error('Algorithm cannot be NULL');
+    }
+
+    if (bytes.idx < bytes.data.length) {
+        params = readOID(bytes);
+    }
+
+    return {
+        alg: alg,
+        params: params,
+    };
+}
+
+function readOID(bytes: Bytes): string | undefined {
+    const tag = readTag(bytes);
+    if (tag.type === TAG_NULL) {
+        return undefined;
+    }
+
+    if (tag.type !== TAG_OID) {
+        throw new Error(`Expected OID tag ${TAG_OID}, but got ${tag.type}`);
+    }
+    const data = bytes.readBytes(tag.size);
+    const ids = [];
+    let ident = 0;
+    let subident = 0;
+    for (const byte of data) {
+        subident = byte;
+        ident <<= 7;
+        ident |= subident & 0x7f;
+        if ((subident & 0x80) === 0) {
+            ids.push(ident);
+            ident = 0;
+        }
+    }
+
+    if (subident & 0x80) {
+        ids.push(ident);
+    }
+
+    const first = (ids[0] / 40) | 0;
+    const second = ids[0] % 40;
+    const result = [first, second].concat(ids.slice(1));
+
+    return result.join('.');
+}
+
+function readEntries(bytes: Bytes): Entry[] {
+    const values = [];
+    bytes = new Bytes(readSeq(bytes));
+
+    while (bytes.idx < bytes.data.length) {
+        const tagSet = readTag(bytes);
+        if (tagSet.type !== TAG_SET) {
+            throw new Error(
+                `Expected set tag ${TAG_SET}, but got ${tagSet.type}`,
+            );
+        }
+        const tagSeq = readTag(bytes);
+        if (tagSeq.type !== TAG_SEQ) {
+            throw new Error(
+                `Expected seq tag ${TAG_SEQ}, but got ${tagSeq.type}`,
+            );
+        }
+        const oid = readOID(bytes);
+        if (oid === undefined) {
+            throw new Error('OID for issuer or subject cannot be NULL');
+        }
+        values.push({
+            oid: oid,
+            value: bytesToStr(readString(bytes)),
+        });
+    }
+
+    return values;
+}
+
+function readTime(bytes: Bytes): number {
+    const tag = readTag(bytes);
+    const decoder = new TextDecoder('ascii', { fatal: true });
+    const str = decoder.decode(bytes.readBytes(tag.size));
+    let year: number;
+    let mon: number;
+    let day: number;
+    let hour: number;
+    let min: number;
+    let sec: number;
+
+    let pos = 0;
+    const readDigits = (numDigits: number) => {
+        const digits = str.slice(pos, pos + numDigits);
+        if (!REGEX_DIGITS.test(digits)) {
+            throw new Error(`Expected ${numDigits} decimal digits`);
+        }
+        pos += numDigits;
+        return Number(digits) | 0;
+    };
+
+    switch (tag.type) {
+        case TAG_UTCTIME: {
+            year = readDigits(2);
+            mon = readDigits(2);
+            day = readDigits(2);
+            hour = readDigits(2);
+            min = readDigits(2);
+            sec = readDigits(2);
+            if (year < 70) {
+                year = 2000 + year;
+            } else {
+                year = 1900 + year;
+            }
+            break;
+        }
+        case TAG_GENTIME: {
+            year = readDigits(4);
+            mon = readDigits(2);
+            day = readDigits(2);
+            hour = readDigits(2);
+            min = readDigits(2);
+            sec = readDigits(2);
+            break;
+        }
+        default: {
+            throw new Error(`Unexpected tag: ${tag.type}.`);
+        }
+    }
+
+    return Date.UTC(year, mon - 1, day, hour, min, sec, 0) / 1000;
+}
+
+function readValidity(bytes: Bytes): Validity {
+    bytes = new Bytes(readSeq(bytes));
+    return {
+        notBefore: readTime(bytes),
+        notAfter: readTime(bytes),
+    };
+}
+
+function readPubkey(bytes: Bytes): PubKey {
+    bytes = new Bytes(readSeq(bytes));
+    return {
+        alg: readAlgIdent(bytes),
+        data: readBitstr(bytes),
+    };
+}
+
+function readToBeSigned(bytes: Bytes): TBS {
+    const startIdx = bytes.idx;
+    const tbsBytes = new Bytes(readSeq(bytes));
+    const endIdx = bytes.idx;
+    return {
+        version: readVersion(tbsBytes),
+        serial: readInt(tbsBytes),
+        sigAlg: readAlgIdent(tbsBytes),
+        issuer: readEntries(tbsBytes),
+        validity: readValidity(tbsBytes),
+        subject: readEntries(tbsBytes),
+        pubkey: readPubkey(tbsBytes),
+        raw: bytes.data.slice(startIdx, endIdx),
+    };
+}
+
+/** Parse a ASN1 certificate from the given bytes */
+export function parseCertRaw(rawCert: Uint8Array): Cert {
+    const bytes = new Bytes(rawCert);
+    const certBytes = new Bytes(readSeq(bytes));
+    return {
+        tbs: readToBeSigned(certBytes),
+        sigAlg: readAlgIdent(certBytes),
+        sig: readBitstr(certBytes),
+        raw: rawCert,
+    };
+}
+
+export function parseCertPem(pem: string): Cert {
+    const match = REGEX_PEM_CERT.exec(pem);
+    if (match === null) {
+        throw new Error(
+            'No PEM encoded certificate found. It should start with ' +
+                '"-----BEGIN CERTIFICATE-----"',
+        );
+    }
+    const certRaw = Uint8Array.from(atob(match[1]), c => c.charCodeAt(0));
+    return parseCertRaw(certRaw);
+}
diff --git a/modules/ecash-lib/src/payment/index.ts b/modules/ecash-lib/src/payment/index.ts
new file mode 100644
--- /dev/null
+++ b/modules/ecash-lib/src/payment/index.ts
@@ -0,0 +1,6 @@
+// Copyright (c) 2025 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+export * as asn1 from './asn1.js';
+export * as x509 from './x509.js';
diff --git a/modules/ecash-lib/src/payment/x509.ts b/modules/ecash-lib/src/payment/x509.ts
new file mode 100644
--- /dev/null
+++ b/modules/ecash-lib/src/payment/x509.ts
@@ -0,0 +1,15 @@
+// Copyright (c) 2025 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+export const OID_ECDSA_WITH_SHA256 = '1.2.840.10045.4.3.2';
+export const OID_EC_PUBLIC_KEY = '1.2.840.10045.2.1';
+export const OID_ANSIP256K1 = '1.3.132.0.10';
+
+export const OID_COUNTRY_NAME = '2.5.4.6';
+export const OID_STATE_OR_PROVINCE_NAME = '2.5.4.8';
+export const OID_LOCALITY_NAME = '2.5.4.7';
+export const OID_ORGANIZATION_NAME = '2.5.4.10';
+export const OID_ORGANIZATIONAL_UNIT_NAME = '2.5.4.11';
+export const OID_COMMON_NAME = '2.5.4.3';
+export const OID_EMAIL_ADDRESS = '1.2.840.113549.1.9.1';