Forráskód Böngészése

Add support for Solana pay v1.0 (#300)

* feat: scaffold solana pay package

* fix: allow .js imports in tests

* feat: parse url

* test: parse url

* feat: encode url

* fix: require https

* feat: error

* feat: request

* refactor: error

* feat: request response

* feat: fetchers

* chore: changeset

* docs: package name
Nick Frostbutter 1 hónapja
szülő
commit
fe74a49fc2

+ 5 - 0
.changeset/sharp-readers-drive.md

@@ -0,0 +1,5 @@
+---
+"@gillsdk/solana-pay": minor
+---
+
+initialize the solana pay package

+ 1 - 0
packages/solana-pay/.gitignore

@@ -0,0 +1 @@
+dist/

+ 1 - 0
packages/solana-pay/.npmrc

@@ -0,0 +1 @@
+engine-strict=true

+ 22 - 0
packages/solana-pay/.prettierignore

@@ -0,0 +1,22 @@
+node_modules
+pnpm-lock.yaml
+
+LICENSE
+.changeset/
+.github/PULL_REQUEST_TEMPLATE.md
+
+declarations/
+dist/
+doc/
+lib/
+kit/
+
+.docs
+.turbo
+.next
+.vercel
+
+**/generated/
+**/generated/**
+generated/
+generated/**

+ 41 - 0
packages/solana-pay/README.md

@@ -0,0 +1,41 @@
+<h1 align="center">
+  @gillsdk/solana-pay
+</h1>
+
+<p align="center">
+  modern Solana Pay protocol client library, built on top of gill 
+</p>
+
+<p align="center">
+  <a href="https://github.com/gillsdk/gill/actions/workflows/publish-packages.yml"><img src="https://img.shields.io/github/actions/workflow/status/gillsdk/gill/publish-packages.yml?logo=GitHub&label=tests" /></a>
+  <a href="https://www.npmjs.com/package/@gillsdk/solana-pay"><img src="https://img.shields.io/npm/v/@gillsdk/solana-pay?logo=npm&color=377CC0" /></a>
+  <a href="https://www.npmjs.com/package/@gillsdk/solana-pay"><img src="https://img.shields.io/npm/dm/@gillsdk/solana-pay?color=377CC0" /></a>
+</p>
+
+## Overview
+
+todo
+
+## Documentation
+
+You can find the gill library docs here:
+
+- [gill docs site](https://gillsdk.com)
+- [gill setup guide](https://gillsdk.com/docs#quick-start)
+- [gill API references](https://gillsdk.com/api)
+
+## Installation
+
+Install `@gillsdk/solana-pay` with your package manager of choice:
+
+```shell
+npm install gill @gillsdk/solana-pay
+```
+
+```shell
+pnpm add gill @gillsdk/solana-pay
+```
+
+```shell
+yarn add gill @gillsdk/solana-pay
+```

+ 78 - 0
packages/solana-pay/package.json

@@ -0,0 +1,78 @@
+{
+  "name": "@gillsdk/solana-pay",
+  "license": "MIT",
+  "version": "0.0.0",
+  "description": "modern Solana Pay protocol client library, built on top of gill",
+  "scripts": {
+    "clean": "rimraf coverage dist build node_modules .turbo",
+    "compile:js": "tsup --config ./tsup.config.package.ts",
+    "compile:typedefs": "tsc -p ./tsconfig.declarations.json",
+    "prepublishOnly": "pnpm pkg delete devDependencies",
+    "publish-impl": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || (pnpm publish --tag ${PUBLISH_TAG:-canary} --access public --no-git-checks && (([ \"$PUBLISH_TAG\" != \"canary\" ] && pnpm dist-tag add $npm_package_name@$npm_package_version latest) || true))",
+    "publish-packages": "pnpm prepublishOnly && pnpm publish-impl",
+    "coverage": "pnpm test:unit:node --coverage",
+    "coverage:open": "export BROWSER=brave && xdg-open ./coverage/lcov-report/index.html > /dev/null",
+    "test:typecheck": "tsc --noEmit",
+    "test:unit:node": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../packages/test-config/jest-unit.config.node.ts --rootDir . --silent",
+    "test:unit:browser": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../packages/test-config/jest-unit.config.browser.ts --rootDir . --silent",
+    "test:treeshakability:browser": "agadoo dist/index.browser.mjs",
+    "test:treeshakability:native": "agadoo dist/index.native.mjs",
+    "test:treeshakability:node": "agadoo dist/index.node.mjs",
+    "style:check": "prettier --check '{*,**/*}.{ts,tsx,js,jsx,css,json,md,mdx}'",
+    "style:fix": "pnpm style:check --write"
+  },
+  "exports": {
+    "types": "./dist/index.d.ts",
+    "import": "./dist/index.node.mjs",
+    "require": "./dist/index.node.cjs",
+    "default": "./dist/index.node.cjs"
+  },
+  "browser": {
+    "./dist/index.node.cjs": "./dist/index.browser.cjs",
+    "./dist/index.node.mjs": "./dist/index.browser.mjs"
+  },
+  "main": "./dist/index.node.cjs",
+  "module": "./dist/index.node.mjs",
+  "react-native": "./dist/index.native.mjs",
+  "types": "./dist/index.d.ts",
+  "type": "module",
+  "files": [
+    "./dist/"
+  ],
+  "sideEffects": false,
+  "keywords": [
+    "blockchain",
+    "solana",
+    "web3",
+    "web3js v2",
+    "solana kit",
+    "wallet",
+    "dapps",
+    "solana helpers",
+    "solana pay",
+    "payments",
+    "@solana/web3.js",
+    "@solana/kit",
+    "@solana/pay",
+    "@solana-developers/helpers",
+    "treeshake"
+  ],
+  "author": "Nick Frostbutter <maintainers@gillsdk.com>",
+  "homepage": "https://gillsdk.com",
+  "bugs": {
+    "url": "https://github.com/gillsdk/gill/issues"
+  },
+  "browserslist": [
+    "supports bigint and not dead",
+    "maintained node versions"
+  ],
+  "engines": {
+    "node": ">=20.18.0"
+  },
+  "dependencies": {
+    "gill": "workspace:*"
+  },
+  "peerDependencies": {
+    "typescript": ">=5"
+  }
+}

+ 138 - 0
packages/solana-pay/src/__tests__/encode-url.ts

@@ -0,0 +1,138 @@
+import assert from "node:assert";
+
+import { type Address } from "gill";
+import { encodeSolanaPayURL } from "../encode-url.js";
+
+describe("encodeSolanaPayURL", () => {
+  describe("TransferRequestURL", () => {
+    const recipient = "nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5" as Address;
+    const splToken = "USDSwr9ApdHk5bvJKMjzff41FfuX8bSxdKcR81vTwcA" as Address;
+    const reference1 = "mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN" as Address;
+    const reference2 = "82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny" as Address;
+    const label = "label";
+    const message = "message";
+    const memo = "memo";
+
+    it("encodes a url with only recipient", () => {
+      const url = encodeSolanaPayURL({ recipient });
+
+      assert.equal(String(url), `solana:${recipient}`);
+    });
+
+    it("encodes a url with recipient and amount", () => {
+      const amount = 1;
+
+      const url = encodeSolanaPayURL({ recipient, amount });
+
+      assert.equal(String(url), `solana:${recipient}?amount=1`);
+    });
+
+    it("encodes a url with recipient, amount, and token", () => {
+      const amount = 1.01;
+
+      const url = encodeSolanaPayURL({ recipient, amount, splToken });
+
+      assert.equal(String(url), `solana:${recipient}?amount=1.01&spl-token=${splToken}`);
+    });
+
+    it("encodes a url with recipient, amount, and single reference", () => {
+      const amount = 100000.123456;
+
+      const url = encodeSolanaPayURL({ recipient, amount, reference: reference1 });
+
+      assert.equal(String(url), `solana:${recipient}?amount=100000.123456&reference=${reference1}`);
+    });
+
+    it("encodes a url with recipient, amount, and multiple references", () => {
+      const amount = 100000.123456;
+      const reference = [reference1, reference2];
+
+      const url = encodeSolanaPayURL({ recipient, amount, reference });
+
+      assert.equal(
+        String(url),
+        `solana:${recipient}?amount=100000.123456&reference=${reference1}&reference=${reference2}`,
+      );
+    });
+
+    it("encodes a url with recipient, amount, and label", () => {
+      const amount = 1.99;
+
+      const url = encodeSolanaPayURL({ recipient, amount, label });
+
+      assert.equal(String(url), `solana:${recipient}?amount=1.99&label=${label}`);
+    });
+
+    it("encodes a url with recipient, message, and amount (as integer)", () => {
+      const amount = 1;
+
+      const url = encodeSolanaPayURL({ recipient, amount, message });
+
+      assert.equal(String(url), `solana:${recipient}?amount=1&message=${message}`);
+    });
+
+    it("encodes a url with recipient, amount, and memo", () => {
+      const amount = 100;
+
+      const url = encodeSolanaPayURL({ recipient, amount, memo });
+
+      assert.equal(String(url), `solana:${recipient}?amount=100&memo=${memo}`);
+    });
+
+    it("encodes a url with recipient, message, and amount (with lots of decimals)", () => {
+      const amount = "0.000000001";
+
+      const url = encodeSolanaPayURL({ recipient, amount, message });
+
+      assert.equal(String(url), `solana:${recipient}?amount=${"0.000000001"}&message=${message}`);
+    });
+
+    // it("encodes a URL with all parameters", () => {
+    //   const amount: number = 0.000000001;
+    //   const reference = [reference1, reference2];
+
+    //   const url = encodeSolanaPayURL({ recipient, amount, splToken, reference, label, message, memo });
+
+    //   assert.equal(
+    //     String(url),
+    //     `solana:${recipient}?amount=${"0.000000001"}&spl-token=${splToken}&reference=${reference1}&reference=${reference2}&label=${label}&message=${message}&memo=${memo}`,
+    //   );
+    // });
+  });
+
+  describe("TransactionRequestURL", () => {
+    const label = "label";
+    const message = "message";
+
+    it("encodes a URL", () => {
+      const link = "https://gillsdk.com";
+
+      const url = encodeSolanaPayURL({ link: new URL(link), label, message });
+
+      assert.equal(String(url), `solana:${link}?label=${label}&message=${message}`);
+    });
+
+    it("encodes a URL with query parameters", () => {
+      const link = "https://gillsdk.com?query=param";
+
+      const url = encodeSolanaPayURL({ link: new URL(link), label, message });
+
+      assert.equal(String(url), `solana:${encodeURIComponent(link)}?label=${label}&message=${message}`);
+    });
+
+    it("throws an error for HTTP", () => {
+      const link = "http://gillsdk.com?query=param";
+      expect(() => encodeSolanaPayURL({ link: new URL(link) })).toThrow("must use HTTPS protocol");
+    });
+
+    it("throws an error for FTP", () => {
+      const link = "ftp://gillsdk.com?query=param";
+      expect(() => encodeSolanaPayURL({ link: new URL(link) })).toThrow("must use HTTPS protocol");
+    });
+
+    it("throws an error for Solana Pay", () => {
+      const link = "solana://gillsdk.com";
+      expect(() => encodeSolanaPayURL({ link: new URL(link) })).toThrow("must use HTTPS protocol");
+    });
+  });
+});

+ 86 - 0
packages/solana-pay/src/__tests__/fetchers.ts

@@ -0,0 +1,86 @@
+import assert from "node:assert";
+
+import { transactionFromBase64, type Address } from "gill";
+import { getTransactionRequest, postTransactionRequest } from "../fetchers.js";
+
+// Mock fetch for testing
+const originalFetch = globalThis.fetch;
+
+function mockFetch(response: any, ok = true, status = 200, statusText = "OK") {
+  globalThis.fetch = async () =>
+    ({
+      ok,
+      status,
+      statusText,
+      json: async () => response,
+    }) as Response;
+}
+
+function restoreFetch() {
+  globalThis.fetch = originalFetch;
+}
+
+describe("HTTP Integration Tests", () => {
+  const account = "nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5" as Address;
+
+  const unsignedTransaction =
+    "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAABC7YxPJkVXZH3qqq8Nq1nwYa5Pm6+M9ZeObND0CCtBLXjfKbGfbEEIU1AEH81ttgpyiNLO+xurYCsjdCVcfR4YQA=";
+  const signedTransaction =
+    "Ace42d/o4XA3NGfL6hslysKyc8kB0ILDUT6diotxWdxP1cdt+oNWGztxEPb5t0F797swnV7NLCguh94nGqetQwABAAABHQ6Thk3MgV/D8oYYCRHQCj/SBt4xoclCh8tD8F/J8rXjfKbGfbEEIU1AEH81ttgpyiNLO+xurYCsjdCVcfR4YQA=";
+
+  const unsignedTx = transactionFromBase64(unsignedTransaction);
+  const signedTx = transactionFromBase64(signedTransaction);
+
+  afterEach(() => {
+    restoreFetch();
+  });
+
+  it("should successfully complete GET transaction request flow", async () => {
+    const mockResponse = {
+      label: "Test Store",
+      icon: "https://example.com/icon.png",
+    };
+
+    mockFetch(mockResponse);
+
+    const url = new URL("https://example.com/pay");
+    const result = await getTransactionRequest(url);
+
+    assert.deepEqual(result, mockResponse);
+  });
+
+  it("should successfully complete POST transaction request flow (with unsigned transaction)", async () => {
+    const mockResponse = {
+      transaction: unsignedTransaction,
+      message: "Payment successful",
+    };
+
+    mockFetch(mockResponse);
+
+    const url = new URL("https://example.com/pay");
+    const result = await postTransactionRequest(url, {
+      account,
+    });
+
+    assert.equal(result.message, mockResponse.message);
+    assert.deepEqual(result.transaction, unsignedTx);
+    // assert.deepEqual(result.transaction.signatures, unsignedTx.signatures);
+  });
+
+  it("should successfully complete POST transaction request flow (with signed transaction)", async () => {
+    const mockResponse = {
+      transaction: signedTransaction,
+      message: "Payment successful",
+    };
+
+    mockFetch(mockResponse);
+
+    const url = new URL("https://example.com/pay");
+    const result = await postTransactionRequest(url, {
+      account,
+    });
+
+    assert.equal(result.message, mockResponse.message);
+    assert.deepEqual(result.transaction, signedTx);
+  });
+});

+ 209 - 0
packages/solana-pay/src/__tests__/parse-url.ts

@@ -0,0 +1,209 @@
+import { type Address } from "gill";
+import assert from "node:assert";
+import { parseSolanaPayURL, SolanaPayTransactionRequestURL, type SolanaPayTransferRequestURL } from "../parse-url.ts";
+
+describe("parseSolanaPayURL", () => {
+  const pubkey = "nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5" as Address;
+
+  describe("SolanaPayTransactionRequestURL", () => {
+    it("should parse with only a url", () => {
+      const url = `solana:${"https://gillsdk.com"}`;
+
+      const { label, message, link } = parseSolanaPayURL(url) as SolanaPayTransactionRequestURL;
+
+      // should add trailing slash
+      assert.equal(link, "https://gillsdk.com/");
+      assert.equal(label, undefined);
+      assert.equal(message, undefined);
+    });
+
+    it("should parse with Solana Pay query params", () => {
+      const data = {
+        link: "https://gillsdk.com/",
+        message: "Message",
+        label: "Label",
+      };
+      const url = `solana:${data.link}?label=${data.label}&message=${data.message}`;
+
+      const { label, message, link } = parseSolanaPayURL(url) as SolanaPayTransactionRequestURL;
+
+      assert.equal(link, data.link);
+      assert.equal(label, data.label);
+      assert.equal(message, data.message);
+    });
+
+    it("should parse with link with query params", () => {
+      const data = {
+        link: "https://gillsdk.com/?query=param",
+      };
+      const url = `solana:${encodeURIComponent(data.link)}`;
+
+      const { label, message, link } = parseSolanaPayURL(url) as SolanaPayTransactionRequestURL;
+
+      assert.equal(link, data.link);
+      assert.equal(label, undefined);
+      assert.equal(message, undefined);
+    });
+
+    it("should parse with link with query params and Solana Pay query params", () => {
+      const data = {
+        link: "https://gillsdk.com/?query=param",
+        message: "Message",
+        label: "Label",
+      };
+      const url = `solana:${encodeURIComponent(data.link)}?label=${data.label}&message=${data.message}`;
+
+      const { label, message, link } = parseSolanaPayURL(url) as SolanaPayTransactionRequestURL;
+
+      assert.equal(link, data.link);
+      assert.equal(label, data.label);
+      assert.equal(message, data.message);
+    });
+  });
+
+  describe("SolanaPayTransferRequestURL", () => {
+    it("should parse with only address", () => {
+      const url = "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5";
+
+      const { recipient, amount, splToken, reference, label, message, memo } = parseSolanaPayURL(
+        url,
+      ) as SolanaPayTransferRequestURL;
+
+      assert.equal(recipient, pubkey);
+      assert.equal(amount, undefined);
+      assert.equal(splToken, undefined);
+      assert.equal(reference, undefined);
+      assert.equal(label, undefined);
+      assert.equal(message, undefined);
+      assert.equal(memo, undefined);
+    });
+
+    it("should parse successfully", () => {
+      const url =
+        "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?amount=0.000000001&reference=82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId5678";
+
+      const { recipient, amount, splToken, reference, label, message, memo } = parseSolanaPayURL(
+        url,
+      ) as SolanaPayTransferRequestURL;
+
+      assert.equal(recipient, pubkey);
+      assert.equal(amount, 0.000000001);
+      assert.equal(splToken, undefined);
+      assert.equal(reference?.length, 1);
+      assert.equal(reference![0], "82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny");
+      assert.equal(label, "Michael");
+      assert.equal(message, "Thanks for all the fish");
+      assert.equal(memo, "OrderId5678");
+    });
+
+    it("should parse with spl-token", () => {
+      const url =
+        "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?amount=1.01&spl-token=82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId5678";
+
+      const { recipient, amount, splToken, reference, label, message, memo } = parseSolanaPayURL(
+        url,
+      ) as SolanaPayTransferRequestURL;
+
+      assert.equal(recipient, pubkey);
+      assert.equal(amount, 1.01);
+      assert.equal(splToken, "82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny");
+      assert.equal(reference, undefined);
+      assert.equal(label, "Michael");
+      assert.equal(message, "Thanks for all the fish");
+      assert.equal(memo, "OrderId5678");
+    });
+
+    it("should parse multiple references", () => {
+      const url =
+        "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?reference=82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny&reference=mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN";
+
+      const { recipient, amount, splToken, reference, label, message, memo } = parseSolanaPayURL(
+        url,
+      ) as SolanaPayTransferRequestURL;
+
+      assert.equal(recipient, pubkey);
+      assert.equal(reference?.length, 2);
+      assert.equal(reference![0], "82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny");
+      assert.equal(reference![1], "mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN");
+      assert.equal(amount, undefined);
+      assert.equal(splToken, undefined);
+      assert.equal(label, undefined);
+      assert.equal(message, undefined);
+      assert.equal(memo, undefined);
+    });
+
+    it("should parse without an amount", () => {
+      const url =
+        "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?reference=82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId5678";
+
+      const { recipient, amount, splToken, reference, label, message, memo } = parseSolanaPayURL(
+        url,
+      ) as SolanaPayTransferRequestURL;
+
+      assert.equal(recipient, pubkey);
+      expect(amount).toBeUndefined();
+      assert.equal(splToken, undefined);
+      assert.equal(reference?.length, 1);
+      assert.equal(reference![0], "82ZJ7nbGpixjeDCmEhUcmwXYfvurzAgGdtSMuHnUgyny");
+      assert.equal(label, "Michael");
+      assert.equal(message, "Thanks for all the fish");
+      assert.equal(memo, "OrderId5678");
+    });
+
+    it("should parse with only amount", () => {
+      const url = "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?amount=0.000000001";
+
+      const { recipient, amount, splToken, reference, label, message, memo } = parseSolanaPayURL(
+        url,
+      ) as SolanaPayTransferRequestURL;
+
+      assert.equal(recipient, pubkey);
+      assert.equal(amount, 0.000000001);
+      assert.equal(splToken, undefined);
+      assert.equal(reference, undefined);
+      assert.equal(label, undefined);
+      assert.equal(message, undefined);
+      assert.equal(memo, undefined);
+    });
+  });
+
+  describe("errors", () => {
+    it("throws an error on invalid length", () => {
+      const url = "X".repeat(2049);
+      expect(() => parseSolanaPayURL(url)).toThrow("length invalid");
+    });
+
+    it("throws an error on invalid protocol", () => {
+      const url = "eth:0xffff";
+      expect(() => parseSolanaPayURL(url)).toThrow("protocol invalid");
+    });
+
+    it("throws an error on invalid recipient", () => {
+      const url = "solana:0xffff";
+      expect(() => parseSolanaPayURL(url)).toThrow("recipient invalid");
+    });
+
+    it.each([
+      // various invalid numbers
+      ["1milliondollars"],
+      [-0.1],
+      [-100],
+    ])("throws an error on invalid amount: %p", (amount) => {
+      const url = `solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?amount=${amount}`;
+
+      expect(() => parseSolanaPayURL(url)).toThrow("amount invalid");
+    });
+
+    it("throws an error on invalid token", () => {
+      const url = "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?amount=1&spl-token=0xffff";
+
+      expect(() => parseSolanaPayURL(url)).toThrow("spl-token invalid");
+    });
+
+    it("throws an error on invalid reference", () => {
+      const url = "solana:nick6zJc6HpW3kfBm4xS2dmbuVRyb5F3AnUvj5ymzR5?amount=1&reference=0xffff";
+
+      expect(() => parseSolanaPayURL(url)).toThrow("reference invalid");
+    });
+  });
+});

+ 238 - 0
packages/solana-pay/src/__tests__/response.ts

@@ -0,0 +1,238 @@
+import assert from "node:assert";
+
+import { transactionFromBase64 } from "gill";
+import { parseSolanaPayGetResponse, parseSolanaPayPostResponse, SolanaPayResponseError } from "../response.js";
+
+describe("parseSolanaPayGetResponse", () => {
+  it("should parse valid GET response", () => {
+    const data = {
+      label: "Test Store",
+      icon: "https://example.com/icon.png",
+    };
+
+    const result = parseSolanaPayGetResponse(data);
+    assert.deepEqual(result, data);
+  });
+
+  it("should throw error for missing label", () => {
+    const data = {
+      icon: "https://example.com/icon.png",
+    };
+
+    assert.throws(
+      () => parseSolanaPayGetResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: missing or invalid label");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for invalid label type", () => {
+    const data = {
+      label: 123, // Should be string
+      icon: "https://example.com/icon.png",
+    };
+
+    assert.throws(
+      () => parseSolanaPayGetResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: missing or invalid label");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for missing icon", () => {
+    const data = {
+      label: "Test Store",
+    };
+
+    assert.throws(
+      () => parseSolanaPayGetResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: missing or invalid icon");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for invalid icon URL format", () => {
+    const data = {
+      label: "Test Store",
+      icon: "not-a-url",
+    };
+
+    assert.throws(
+      () => parseSolanaPayGetResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid icon URL format");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for non-HTTP(S) icon URL", () => {
+    const data = {
+      label: "Test Store",
+      icon: "ftp://example.com/icon.png",
+    };
+
+    assert.throws(
+      () => parseSolanaPayGetResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Icon URL must use HTTP or HTTPS protocol");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for invalid icon format", () => {
+    const data = {
+      label: "Test Store",
+      icon: "https://example.com/icon.gif",
+    };
+
+    assert.throws(
+      () => parseSolanaPayGetResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Icon must be SVG, PNG, WebP, or JPEG format");
+        return true;
+      },
+    );
+  });
+
+  it("should accept valid icon formats", () => {
+    const formats = [".svg", ".png", ".webp", ".jpg", ".jpeg"];
+
+    for (const format of formats) {
+      const data = {
+        label: "Test Store",
+        icon: `https://example.com/icon${format}`,
+      };
+
+      const result = parseSolanaPayGetResponse(data);
+      assert.equal(result.icon, data.icon);
+    }
+  });
+
+  it("should handle case-insensitive file extensions", () => {
+    const data = {
+      label: "Test Store",
+      icon: "https://example.com/icon.PNG",
+    };
+
+    const result = parseSolanaPayGetResponse(data);
+    assert.equal(result.icon, data.icon);
+  });
+});
+
+describe("parseSolanaPayPostResponse", () => {
+  const unsignedTransaction =
+    "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAABC7YxPJkVXZH3qqq8Nq1nwYa5Pm6+M9ZeObND0CCtBLXjfKbGfbEEIU1AEH81ttgpyiNLO+xurYCsjdCVcfR4YQA=";
+  const signedTransaction =
+    "Ace42d/o4XA3NGfL6hslysKyc8kB0ILDUT6diotxWdxP1cdt+oNWGztxEPb5t0F797swnV7NLCguh94nGqetQwABAAABHQ6Thk3MgV/D8oYYCRHQCj/SBt4xoclCh8tD8F/J8rXjfKbGfbEEIU1AEH81ttgpyiNLO+xurYCsjdCVcfR4YQA=";
+
+  const unsignedTx = transactionFromBase64(unsignedTransaction);
+  const signedTx = transactionFromBase64(signedTransaction);
+
+  it("should parse valid POST response (unsigned transaction)", () => {
+    const data = {
+      transaction: unsignedTransaction,
+      message: "Payment successful",
+    };
+
+    const result = parseSolanaPayPostResponse(data);
+    assert.equal(result.message, data.message);
+    assert.deepEqual(result.transaction, unsignedTx);
+  });
+
+  it("should parse valid POST response (signed transaction)", () => {
+    const data = {
+      transaction: signedTransaction,
+      message: "Payment successful",
+    };
+
+    const result = parseSolanaPayPostResponse(data);
+    assert.equal(result.message, data.message);
+    assert.deepEqual(result.transaction, signedTx);
+  });
+
+  it("should parse POST response without message", () => {
+    const data = {
+      // transaction: "dGVzdA==", // "test" in base64
+      transaction: unsignedTransaction,
+    };
+
+    const result = parseSolanaPayPostResponse(data);
+    assert.equal(result.message, undefined);
+    assert.deepEqual(result.transaction, unsignedTx);
+  });
+
+  it("should throw error for missing transaction", () => {
+    const data = {
+      message: "Payment successful",
+    };
+
+    assert.throws(
+      () => parseSolanaPayPostResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: missing or invalid transaction");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for non-string transaction", () => {
+    const data = {
+      transaction: 123, // Should be string
+    };
+
+    assert.throws(
+      () => parseSolanaPayPostResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: missing or invalid transaction");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for empty transaction", () => {
+    const data = {
+      transaction: "",
+    };
+
+    assert.throws(
+      () => parseSolanaPayPostResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: missing or invalid transaction");
+        return true;
+      },
+    );
+  });
+
+  it("should throw error for invalid message type", () => {
+    const data = {
+      transaction: unsignedTransaction,
+      message: 123, // Should be string
+    };
+
+    assert.throws(
+      () => parseSolanaPayPostResponse(data),
+      (err: SolanaPayResponseError) => {
+        assert(err instanceof SolanaPayResponseError);
+        assert.equal(err.message, "Invalid response: message must be string");
+        return true;
+      },
+    );
+  });
+});

+ 2 - 0
packages/solana-pay/src/constants.ts

@@ -0,0 +1,2 @@
+/** @internal */
+export const SOLANA_PAY_PROTOCOL = "solana:";

+ 111 - 0
packages/solana-pay/src/encode-url.ts

@@ -0,0 +1,111 @@
+import type { Address } from "gill";
+import { SOLANA_PAY_PROTOCOL } from "./constants.js";
+
+/**
+ * Fields of a Solana Pay transaction request URL.
+ */
+export interface SolanaPayTransactionRequestURLFields {
+  /** `link` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#link) */
+  link: URL;
+  /** `label` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#label-1) */
+  label?: string;
+  /** `message` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#message-1).  */
+  message?: string;
+}
+
+/**
+ * Fields of a Solana Pay transfer request URL.
+ */
+export interface SolanaPayTransferRequestURLFields {
+  /** `recipient` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#recipient) */
+  recipient: Address;
+  /** `amount` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#amount) */
+  amount?: number | string;
+  /** `spl-token` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#spl-token) */
+  splToken?: Address;
+  /** `reference` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#reference) */
+  reference?: Address | Address[];
+  /** `label` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#label) */
+  label?: string;
+  /** `message` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#message).  */
+  message?: string;
+  /** `memo` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#memo) */
+  memo?: string;
+}
+
+/**
+ * Encode a Solana Pay URL
+ *
+ * @param fields Fields to encode in the URL
+ */
+export function encodeSolanaPayURL(
+  fields: SolanaPayTransactionRequestURLFields | SolanaPayTransferRequestURLFields,
+): URL {
+  return "link" in fields ? encodeTransactionRequestURL(fields) : encodeTransferRequestURL(fields);
+}
+
+function encodeTransactionRequestURL({ link, label, message }: SolanaPayTransactionRequestURLFields): URL {
+  if (link.protocol !== "https:") {
+    throw new Error("Link must use HTTPS protocol");
+  }
+
+  // Remove trailing slashes
+  const pathname = link.search
+    ? encodeURIComponent(String(link).replace(/\/\?/, "?"))
+    : String(link).replace(/\/$/, "");
+  const url = new URL(SOLANA_PAY_PROTOCOL + pathname);
+
+  if (label) {
+    url.searchParams.append("label", label);
+  }
+
+  if (message) {
+    url.searchParams.append("message", message);
+  }
+
+  return url;
+}
+
+function encodeTransferRequestURL({
+  recipient,
+  amount,
+  splToken,
+  reference,
+  label,
+  message,
+  memo,
+}: SolanaPayTransferRequestURLFields): URL {
+  const url = new URL(SOLANA_PAY_PROTOCOL + recipient);
+
+  if (amount) {
+    url.searchParams.append("amount", amount.toString());
+  }
+
+  if (splToken) {
+    url.searchParams.append("spl-token", splToken);
+  }
+
+  if (reference) {
+    if (!Array.isArray(reference)) {
+      reference = [reference];
+    }
+
+    for (const pubkey of reference) {
+      url.searchParams.append("reference", pubkey);
+    }
+  }
+
+  if (label) {
+    url.searchParams.append("label", label);
+  }
+
+  if (message) {
+    url.searchParams.append("message", message);
+  }
+
+  if (memo) {
+    url.searchParams.append("memo", memo);
+  }
+
+  return url;
+}

+ 66 - 0
packages/solana-pay/src/fetchers.ts

@@ -0,0 +1,66 @@
+import { SolanaPayTransactionRequestPostRequest, validateSolanaPayRequestUrl } from "./request.js";
+import {
+  parseSolanaPayGetResponse,
+  parseSolanaPayPostResponse,
+  SolanaPayTransactionRequestGetResponse,
+  SolanaPayTransactionRequestPostResponse,
+} from "./response.js";
+
+export async function getTransactionRequest(
+  url: URL,
+  options?: RequestInit,
+): Promise<SolanaPayTransactionRequestGetResponse> {
+  validateSolanaPayRequestUrl(url);
+
+  const response = await fetch(url, {
+    method: "GET",
+    headers: {
+      Accept: "application/json",
+      "Accept-Encoding": "gzip, deflate, br",
+      ...options?.headers,
+    },
+    ...options,
+  });
+
+  if (!response.ok) {
+    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+  }
+
+  const data = await response.json();
+  return parseSolanaPayGetResponse(data);
+}
+
+export async function postTransactionRequest(
+  url: URL,
+  body: SolanaPayTransactionRequestPostRequest,
+  options?: RequestInit,
+): Promise<SolanaPayTransactionRequestPostResponse> {
+  validateSolanaPayRequestUrl(url);
+
+  const response = await fetch(url, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Accept: "application/json",
+      "Accept-Encoding": "gzip, deflate, br",
+      ...options?.headers,
+    },
+    body: JSON.stringify(body),
+    ...options,
+  });
+
+  if (!response.ok) {
+    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+  }
+
+  const data = await response.json();
+  return parseSolanaPayPostResponse(data);
+}
+
+/**
+ *
+ */
+export const solanaPayTransactionRequest = {
+  get: getTransactionRequest,
+  post: getTransactionRequest,
+};

+ 5 - 0
packages/solana-pay/src/global.d.ts

@@ -0,0 +1,5 @@
+declare const __BROWSER__: boolean;
+declare const __DEV__: boolean;
+declare const __NODEJS__: boolean;
+declare const __REACTNATIVE__: boolean;
+declare const __VERSION__: string;

+ 6 - 0
packages/solana-pay/src/index.ts

@@ -0,0 +1,6 @@
+export * from "./constants.js";
+export * from "./encode-url.js";
+export { solanaPayTransactionRequest } from "./fetchers.js";
+export * from "./parse-url.js";
+export * from "./request.js";
+export * from "./response.js";

+ 134 - 0
packages/solana-pay/src/parse-url.ts

@@ -0,0 +1,134 @@
+import { address, type Address } from "gill";
+// import BigNumber from "bignumber.js";
+import { SOLANA_PAY_PROTOCOL } from "./constants.js";
+
+/**
+ * A Solana Pay transaction request URL
+ */
+export interface SolanaPayTransactionRequestURL {
+  /** `link` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#link) */
+  link: URL;
+  /** `label` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#label-1) */
+  label?: string;
+  /** `message` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#message-1) */
+  message?: string;
+}
+
+/**
+ * A Solana Pay transfer request URL
+ */
+export interface SolanaPayTransferRequestURL {
+  /** `recipient` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#recipient) */
+  recipient: Address;
+  /** `amount` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#amount) */
+  amount?: number;
+  /** `spl-token` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#spl-token) */
+  splToken: Address | undefined;
+  /** `reference` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#reference) */
+  reference?: Address[];
+  /** `label` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#label) */
+  label?: string;
+  /** `message` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#message) */
+  message?: string;
+  /** `memo` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#memo) */
+  memo?: string;
+}
+
+/**
+ * Thrown when a URL can't be parsed as a Solana Pay URL
+ */
+export class SolanaPayParseURLError extends Error {
+  name = "SolanaPayParseURLError";
+}
+
+/**
+ * Parse a Solana Pay URL as a Transfer Request or Transaction Request
+ *
+ * @param url - URL to parse
+ *
+ * @throws {SolanaPayParseURLError}
+ */
+export function parseSolanaPayURL(url: string | URL): SolanaPayTransactionRequestURL | SolanaPayTransferRequestURL {
+  if (typeof url === "string") {
+    if (url.length > 2048) throw new SolanaPayParseURLError("length invalid");
+    url = new URL(url);
+  }
+
+  if (url.protocol !== SOLANA_PAY_PROTOCOL) throw new SolanaPayParseURLError("protocol invalid");
+  if (!url.pathname) throw new SolanaPayParseURLError("missing pathname");
+
+  return /[:%]/.test(url.pathname) ? parseTransactionRequestURL(url) : parseTransferRequestURL(url);
+}
+
+function parseTransactionRequestURL({ pathname, searchParams }: URL): SolanaPayTransactionRequestURL {
+  const link = new URL(decodeURIComponent(pathname));
+  if (link.protocol !== "https:") throw new SolanaPayParseURLError("link invalid");
+
+  const label = searchParams.get("label") || undefined;
+  const message = searchParams.get("message") || undefined;
+
+  return {
+    link,
+    label,
+    message,
+  };
+}
+
+function parseTransferRequestURL({ pathname, searchParams }: URL): SolanaPayTransferRequestURL {
+  let recipient: Address;
+  try {
+    recipient = address(pathname);
+  } catch (error: any) {
+    throw new SolanaPayParseURLError("recipient invalid");
+  }
+
+  let amount: number | undefined;
+  const amountParam = searchParams.get("amount");
+  if (amountParam != null) {
+    if (!/^\d+(\.\d+)?$/.test(amountParam)) throw new SolanaPayParseURLError("amount invalid");
+
+    try {
+      amount = parseFloat(amountParam);
+    } catch (err) {
+      throw new SolanaPayParseURLError("amount invalid");
+    }
+    if (!amount) throw new SolanaPayParseURLError("amount invalid");
+    if (Number.isNaN(amount)) throw new SolanaPayParseURLError("amount NaN");
+    if (amount < 0) throw new SolanaPayParseURLError("amount negative");
+    // 0 is a valid `amount`
+  }
+
+  let splToken: Address | undefined;
+  const splTokenParam = searchParams.get("spl-token");
+  if (splTokenParam != null) {
+    try {
+      splToken = address(splTokenParam);
+    } catch (error) {
+      throw new SolanaPayParseURLError("spl-token invalid");
+    }
+  }
+
+  let reference: Address[] | undefined;
+  const referenceParams = searchParams.getAll("reference");
+  if (referenceParams.length) {
+    try {
+      reference = referenceParams.map((reference) => address(reference));
+    } catch (error) {
+      throw new SolanaPayParseURLError("reference invalid");
+    }
+  }
+
+  const label = searchParams.get("label") || undefined;
+  const message = searchParams.get("message") || undefined;
+  const memo = searchParams.get("memo") || undefined;
+
+  return {
+    recipient,
+    amount,
+    splToken,
+    reference,
+    label,
+    message,
+    memo,
+  };
+}

+ 15 - 0
packages/solana-pay/src/request.ts

@@ -0,0 +1,15 @@
+import { Address } from "gill";
+
+export interface SolanaPayTransactionRequestGetRequest {
+  // get request takes not data
+}
+
+export interface SolanaPayTransactionRequestPostRequest {
+  account: Address;
+}
+
+export function validateSolanaPayRequestUrl(url: URL): void {
+  if (url.protocol !== "https:") {
+    throw new Error("URL must use HTTPS protocol");
+  }
+}

+ 82 - 0
packages/solana-pay/src/response.ts

@@ -0,0 +1,82 @@
+import { transactionFromBase64, type Transaction } from "gill";
+
+export class SolanaPayResponseError extends Error {
+  name = "SolanaPayResponseError";
+}
+
+export interface SolanaPayTransactionRequestGetResponse {
+  label: string;
+  icon: string;
+}
+
+export interface SolanaPayTransactionRequestPostResponse {
+  transaction: Transaction;
+  message?: string;
+}
+
+/**
+ * Parse provided input to be a valid Solana Pay Transaction Request's
+ * [GET response](https://github.com/solana-foundation/solana-pay/blob/master/SPEC.md#get-response)
+ * per the spec
+ */
+export function parseSolanaPayGetResponse(data: any): SolanaPayTransactionRequestGetResponse {
+  if (!data.label || typeof data.label !== "string") {
+    throw new SolanaPayResponseError("Invalid response: missing or invalid label");
+  }
+
+  if (!data.icon || typeof data.icon !== "string") {
+    throw new SolanaPayResponseError("Invalid response: missing or invalid icon");
+  }
+
+  let iconUrl: URL;
+  try {
+    iconUrl = new URL(data.icon);
+  } catch {
+    throw new SolanaPayResponseError("Invalid icon URL format");
+  }
+
+  if (iconUrl.protocol !== "http:" && iconUrl.protocol !== "https:") {
+    throw new SolanaPayResponseError("Icon URL must use HTTP or HTTPS protocol");
+  }
+
+  // jpg is not in the v1.1 spec, but they should be :)
+  const allowedExtensions = [".svg", ".png", ".webp", ".jpg", ".jpeg"];
+  const hasValidExtension = allowedExtensions.some((ext) => iconUrl.pathname.toLowerCase().endsWith(ext));
+
+  if (!hasValidExtension) {
+    throw new SolanaPayResponseError("Icon must be SVG, PNG, WebP, or JPEG format");
+  }
+
+  return {
+    label: data.label,
+    icon: data.icon,
+  };
+}
+
+/**
+ * Parse provided input to be a valid Solana Pay Transaction Request's
+ * [POST response](https://github.com/solana-foundation/solana-pay/blob/master/SPEC.md#post-response)
+ * per the spec
+ */
+export function parseSolanaPayPostResponse(data: any): SolanaPayTransactionRequestPostResponse {
+  if (!data.transaction || typeof data.transaction !== "string") {
+    throw new SolanaPayResponseError("Invalid response: missing or invalid transaction");
+  }
+
+  if (data.message && typeof data.message !== "string") {
+    throw new SolanaPayResponseError("Invalid response: message must be string");
+  }
+
+  let transaction: Transaction | null = null;
+
+  try {
+    transaction = transactionFromBase64(data.transaction);
+  } catch {
+    throw new SolanaPayResponseError("Invalid transaction data as base64");
+  }
+
+  return {
+    transaction,
+    message: data.message,
+  };
+}

+ 10 - 0
packages/solana-pay/tsconfig.declarations.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "declarationMap": true,
+    "emitDeclarationOnly": true,
+    "outDir": "./dist"
+  },
+  "include": ["src/index.ts", "src/types"]
+}

+ 6 - 0
packages/solana-pay/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "display": "solana-pay",
+  "extends": "../tsconfig/base.json",
+  "include": ["src/*"]
+}

+ 9 - 0
packages/solana-pay/tsup.config.package.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from "tsup";
+
+import { getBaseConfig } from "../build-scripts/getBaseConfig";
+
+export default defineConfig((options = {}) => [
+  ...getBaseConfig("node", ["cjs", "esm"], options),
+  ...getBaseConfig("browser", ["cjs", "esm"], options),
+  ...getBaseConfig("native", ["esm"], options),
+]);

+ 4 - 0
packages/test-config/jest-unit.config.common.ts

@@ -19,6 +19,10 @@ const config: Partial<Config.InitialProjectOptions> = {
     ".*reexports|index.ts",
   ],
   testPathIgnorePatterns: ["__setup__.ts"],
+  moduleNameMapper: {
+    // Handle .js imports to .ts files for ESM compatibility
+    "^(\\.{1,2}/.*)\\.js$": "$1",
+  },
   transform: {
     "^.+\\.(ts|js)x?$": [
       "@swc/jest",

+ 9 - 0
pnpm-lock.yaml

@@ -253,6 +253,15 @@ importers:
         specifier: ^18
         version: 18.3.1
 
+  packages/solana-pay:
+    dependencies:
+      gill:
+        specifier: workspace:*
+        version: link:../gill
+      typescript:
+        specifier: '>=5'
+        version: 5.8.3
+
   packages/svelte:
     dependencies:
       gill: