diff --git a/SPEC.md b/SPEC.md index 507bd470..285969f9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,18 +1,18 @@ # Solana Pay Specification ## Summary -A standard protocol to encode Solana transaction requests within URLs to enable payments and other use cases. +A standard protocol to encode Solana transaction and message-signing requests within URLs to enable payments, authentication, and other use cases. Rough consensus on this spec has been reached, and implementations exist in Phantom, FTX, and Slope. This standard draws inspiration from [BIP 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) and [EIP 681](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-681.md). ## Motivation -A standard URL protocol for requesting native SOL transfers, SPL Token transfers, and Solana transactions allows for a better user experience across apps and wallets in the Solana ecosystem. +A standard URL protocol for requesting native SOL transfers, SPL Token transfers, Solana transactions, and message signing allows for a better user experience across apps and wallets in the Solana ecosystem. -These URLs may be encoded in QR codes or NFC tags, or sent between users and applications to request payment and compose transactions. +These URLs may be encoded in QR codes or NFC tags, or sent between users and applications to request payment, compose transactions, and sign messages. -Applications should ensure that a transaction has been confirmed and is valid before they release goods or services being sold, or grant access to objects or events. +Applications should ensure that a transaction has been confirmed, or that a signed message is valid, before they release goods or services being sold, or grant access to objects or events. Mobile wallets should register to handle the URL scheme to provide a seamless yet secure experience when Solana Pay URLs are encountered in the environment. @@ -95,16 +95,20 @@ solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=0.01&spl-token=EPjFWdd solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN&label=Michael ``` -## Specification: Transaction Request +## Specification: Interactive Request -A Solana Pay transaction request URL describes an interactive request for any Solana transaction. +A Solana Pay interactive request URL describes an interactive request where the parameters in the URL are used by a wallet to make an HTTP request to a remote server. Currently two types of interactive requests exist: + +1. Transaction Request: A Solana Pay transaction request URL describes an interactive request that returns any Solana transaction. +2. Sign-message Request: A Solana Pay sign-message request URL describes an interactive request that is used to verify ownership of an address. + +The request URL structure for both types of interactive requests are the same. As such, wallets will not know which type of interaction is being requested until the POST request response payload is received from the server. + +### Link ```html solana: ``` -The request is interactive because the parameters in the URL are used by a wallet to make an HTTP request to compose a transaction. - -### Link A single `link` field is required as the pathname. The value must be a conditionally [URL-encoded](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) absolute HTTPS URL. If the URL contains query parameters, it must be URL-encoded. Protocol query parameters may be added to this specification. URL-encoding the value prevents conflicting with protocol parameters. @@ -136,6 +140,8 @@ The wallet should not cache the response except as instructed by [HTTP caching]( The wallet should display the label and render the icon image to user. +### Transction Request + #### POST Request The wallet must make an HTTP `POST` JSON request to the URL with a body of @@ -188,21 +194,21 @@ For example, this might be the name of an item being purchased, a discount appli The wallet and application should allow additional fields in the request body and response body, which may be added by future specification. -### Example +### Transation Request Example ##### URL describing a transaction request. ``` -solana:https://example.com/solana-pay +solana:https://example.com/solana-pay/transction-request ``` ##### URL describing a transaction request with query parameters. ``` -solana:https%3A%2F%2Fexample.com%2Fsolana-pay%3Forder%3D12345 +solana:https%3A%2F%2Fexample.com%2Fsolana-pay%2Ftransaction-request%3Forder%3D12345 ``` ##### GET Request ``` -GET /solana-pay?order=12345 HTTP/1.1 +GET /solana-pay/transaction-request?order=12345 HTTP/1.1 Host: example.com Connection: close Accept: application/json @@ -222,7 +228,7 @@ Content-Encoding: gzip ##### POST Request ``` -POST /solana-pay?order=12345 HTTP/1.1 +POST /solana-pay/transction-request?order=12345 HTTP/1.1 Host: example.com Connection: close Accept: application/json @@ -243,6 +249,151 @@ Content-Encoding: gzip {"message":"Thanks for all the fish","transaction":"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAECC4JMKqNplIXybGb/GhK1ofdVWeuEjXnQor7gi0Y2hMcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQECAAAMAgAAAAAAAAAAAAAA"} ``` +### Sign-message Request + +#### POST Request + +The wallet must make an HTTP `POST` JSON request to the URL with a body of +```json +{"account":""} +``` + +The `` value must be the base58-encoded public key of the account that will sign the message. + +The wallet should make the request with an [Accept-Encoding header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding), and the application should respond with a [Content-Encoding header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) for HTTP compression. + +The wallet should display the domain of the URL as the request is being made. If a `GET` request was made, the wallet should also display the label and render the icon image from the response. + +#### POST Response + +The wallet must handle HTTP [client error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses), [server error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#server_error_responses), and [redirect responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). The application must respond with these, or with an HTTP `OK` JSON response with a body of +```json +{"data":"","state":""} +``` + +The `` value must be a [UTF-8](https://developer.mozilla.org/en-US/docs/Glossary/UTF-8) string value. The wallet must sign the `data` value with the private key that corresponds to the `account` in the request and send the resulting signature back to the server in the proceeding [PUT request](https://github.com/bedrock-foundation/solana-pay/edit/master/SPEC.md#put-request). + +The `` value must be a [UTF-8](https://developer.mozilla.org/en-US/docs/Glossary/UTF-8) string value that functions as a [MAC](https://en.wikipedia.org/wiki/Message_authentication_code). The wallet will pass this value back to the server in the [PUT request](https://github.com/bedrock-foundation/solana-pay/edit/master/SPEC.md#put-request) in order to verify that the contents of the `` field were not modified. + + +The application may also include an optional `message` field in the response body: +```json +{"message":"","data":"","state":""} +``` + +The `` value must be a UTF-8 encoded string that describes the nature of the sign-message response. + +For example, this might be the name of the application or event with which the user is interacting, context about how the sign-message request is being used, or a thank you note. The wallet should display the value to the user. + +The wallet and application should allow additional fields in the request body and response body, which may be added by future specification. + +#### PUT Request + +The PUT request is used to send the results of signing the message back to the server. The wallet must make an HTTP `PUT` JSON request to the URL with a body of +```json +{"account":"","state":"","signature":""} +``` + +The `` value must be the base58-encoded public key of the account that signed the message. + +The `` value must be the unmodifed `` value from the response of the preceeding POST request. + +The `` value is the base58-encoded signature from signing the `` field with the users private key. + +The wallet should make the request with an [Accept-Encoding header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding), and the application should respond with a [Content-Encoding header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) for HTTP compression. + +The wallet should display the domain of the URL as the request is being made. If a `GET` request was made, the wallet should also display the label and render the icon image from the response. + +#### PUT Response + +The wallet must handle HTTP [client error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses), [server error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#server_error_responses), and [redirect responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). The application must respond with these, or with an HTTP `OK` JSON response with a body of +```json +{"success":""} +``` + +The `` value must be a boolean value indicating whether signature verification succeeded or failed. + +The wallet and application should allow additional fields in the request body and response body, which may be added by future specification. + +### Sign-message Request Example + +##### URL describing a sign-message request. +``` +solana:https://example.com/solana-pay/sign-message +``` + +##### URL describing a sign-message request with query parameters. +``` +solana:https%3A%2F%2Fexample.com%2Fsolana-pay%2Fsign-message%3Fid%3D678910 +``` + +##### GET Request +``` +GET /solana-pay/sign-message?id=678910 HTTP/1.1 +Host: example.com +Connection: close +Accept: application/json +Accept-Encoding: br, gzip, deflate +``` + +##### GET Response +``` +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json +Content-Length: 62 +Content-Encoding: gzip + +{"label":"Michael Vines","icon":"https://example.com/icon.svg"} +``` + +##### POST Request +``` +POST /solana-pay/sign-message?id=678910 HTTP/1.1 +Host: example.com +Connection: close +Accept: application/json +Accept-Encoding: br, gzip, deflate +Content-Type: application/json +Content-Length: 57 + +{"account":"mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN"} +``` + +##### POST Response +``` +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json +Content-Length: 298 +Content-Encoding: gzip + +{"message":"Sign the message to login","data":"SIGN_THIS_MESSAGE","state":"eyJhbGciOiJIUzI1NiJ9.U0lHTl9USElTX01FU1NBR0U.KcZ1FnrT1ImAL-7LbALfZOx9F4I4LMuEE8_bg5Zmec4"} +``` + +##### PUT Request +``` +POST /solana-pay/sign-message?id=678910 HTTP/1.1 +Host: example.com +Connection: close +Accept: application/json +Accept-Encoding: br, gzip, deflate +Content-Type: application/json +Content-Length: 57 + +{"account":"mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN","signature":"3ApozYFyp2ZxWuGvJS7Q1oV8M3YsLMV3WmwbjGCgktqXfdevjCZ92vA4F9V7Xj7KrN7JTtYStBSBeWnNN7vyHkg5","state":"eyJhbGciOiJIUzI1NiJ9.U0lHTl9USElTX01FU1NBR0U.KcZ1FnrT1ImAL-7LbALfZOx9F4I4LMuEE8_bg5Zmec4"} +``` + +##### PUT Response +``` +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json +Content-Length: 298 +Content-Encoding: gzip + +{"success":true} +``` ## Extensions diff --git a/core/package-lock.json b/core/package-lock.json index fb9c4f7f..5c422395 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solana/pay", - "version": "0.2.2", + "version": "0.2.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@solana/pay", - "version": "0.2.2", + "version": "0.2.4", "license": "Apache-2.0", "dependencies": { "@solana/qr-code-styling": "^1.6.0-beta.0", diff --git a/core/src/encodeURL.ts b/core/src/encodeURL.ts index 2689d637..d65befef 100644 --- a/core/src/encodeURL.ts +++ b/core/src/encodeURL.ts @@ -2,9 +2,9 @@ import { SOLANA_PROTOCOL } from './constants.js'; import type { Amount, Label, Memo, Message, Recipient, References, SPLToken } from './types.js'; /** - * Fields of a Solana Pay transaction request URL. + * Fields of a Solana Pay transaction or message signing request URL. */ -export interface TransactionRequestURLFields { +export interface InteractiveRequestURLFields { /** `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). */ @@ -38,11 +38,11 @@ export interface TransferRequestURLFields { * * @param fields Fields to encode in the URL. */ -export function encodeURL(fields: TransactionRequestURLFields | TransferRequestURLFields): URL { - return 'link' in fields ? encodeTransactionRequestURL(fields) : encodeTransferRequestURL(fields); +export function encodeURL(fields: InteractiveRequestURLFields | TransferRequestURLFields): URL { + return 'link' in fields ? encodeInteractiveRequestURL(fields) : encodeTransferRequestURL(fields); } -function encodeTransactionRequestURL({ link, label, message }: TransactionRequestURLFields): URL { +function encodeInteractiveRequestURL({ link, label, message }: InteractiveRequestURLFields): URL { // Remove trailing slashes const pathname = link.search ? encodeURIComponent(String(link).replace(/\/\?/, '?')) diff --git a/core/src/fetchInteraction.ts b/core/src/fetchInteraction.ts new file mode 100644 index 00000000..14e745e7 --- /dev/null +++ b/core/src/fetchInteraction.ts @@ -0,0 +1,147 @@ +import type { Commitment, Connection, PublicKey } from '@solana/web3.js'; +import { Transaction } from '@solana/web3.js'; +import fetch from 'cross-fetch'; +import { toUint8Array } from 'js-base64'; +import nacl from 'tweetnacl'; + +/** + * Thrown when a transaction response can't be fetched. + */ +export class FetchInteractionError extends Error { + name = 'FetchInteractionError'; +} + +export interface FetchTransactionResponse { + transaction: Transaction; + message?: string; +} + +export interface FetchSignMessageResponse { + data: string; + state: string; + message?: string; +} + +export interface FetchInteractionErrorResponse { + message?: string; +} + +export type FetchInteractionResponse = + | FetchTransactionResponse + | FetchSignMessageResponse + | FetchInteractionErrorResponse; + +interface FetchInteractionServerResponse { + transaction?: string; + data?: string; + state?: string; + message?: string; +} + +export const isTransactionResponse = (value: FetchInteractionResponse): value is FetchTransactionResponse => { + return 'transaction' in value; +}; + +export const isSignMessageResponse = (value: FetchInteractionResponse): value is FetchSignMessageResponse => { + return 'data' in value && `state` in value; +}; + +export const isErrorResponse = (value: FetchInteractionErrorResponse): value is FetchInteractionErrorResponse => { + return !isSignMessageResponse(value) && !isTransactionResponse(value); +}; + +/** + * Fetch a transaction from a Solana Pay transaction request link. + * + * @param connection - A connection to the cluster. + * @param account - Account that may sign the transaction. + * @param link - `link` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#link). + * @param options - Options for `getLatestBlockhash`. + * + * @throws {FetchInteractionError} + */ +export async function fetchInteraction( + connection: Connection, + account: PublicKey, + link: string | URL, + { commitment }: { commitment?: Commitment } = {} +): Promise { + const response = await fetch(String(link), { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'omit', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account }), + }); + + const json: FetchInteractionServerResponse = await response.json(); + + if (!json.transaction && !json.state && !json.data && !json.message) { + throw new FetchInteractionError('invalid response'); + } + + /** + * Transaction Request validation and parsing + */ + + if (json.transaction) { + if (json.data || json.state) throw new FetchInteractionError('invalid transaction response'); + if (typeof json.transaction !== 'string') throw new FetchInteractionError('invalid transaction'); + const transaction = Transaction.from(toUint8Array(json.transaction)); + const { signatures, feePayer, recentBlockhash } = transaction; + + if (signatures.length) { + if (!feePayer) throw new FetchInteractionError('missing fee payer'); + if (!feePayer.equals(signatures[0].publicKey)) throw new FetchInteractionError('invalid fee payer'); + if (!recentBlockhash) throw new FetchInteractionError('missing recent blockhash'); + + // A valid signature for everything except `account` must be provided. + const message = transaction.serializeMessage(); + for (const { signature, publicKey } of signatures) { + if (signature) { + if (!nacl.sign.detached.verify(message, signature, publicKey.toBuffer())) + throw new FetchInteractionError('invalid signature'); + } else if (publicKey.equals(account)) { + // If the only signature expected is for `account`, ignore the recent blockhash in the transaction. + if (signatures.length === 1) { + transaction.recentBlockhash = (await connection.getLatestBlockhash(commitment)).blockhash; + } + } else { + throw new FetchInteractionError('missing signature'); + } + } + } else { + // Ignore the fee payer and recent blockhash in the transaction and initialize them. + transaction.feePayer = account; + transaction.recentBlockhash = (await connection.getLatestBlockhash(commitment)).blockhash; + } + + return { + transaction, + message: json.message, + }; + } + + /** + * Sign Message Request validation and parsing + */ + + if (json.data) { + if (typeof json.data !== 'string') throw new FetchInteractionError('invalid data field'); + if (typeof json.state !== 'string') throw new FetchInteractionError('invalid state field'); + + return { + data: json.data, + state: json.state, + message: json.message, + }; + } + + return { + message: json.message, + }; +} diff --git a/core/src/fetchTransaction.ts b/core/src/fetchTransaction.ts index e371b12b..637d08b0 100644 --- a/core/src/fetchTransaction.ts +++ b/core/src/fetchTransaction.ts @@ -1,8 +1,6 @@ import type { Commitment, Connection, PublicKey } from '@solana/web3.js'; -import { Transaction } from '@solana/web3.js'; -import fetch from 'cross-fetch'; -import { toUint8Array } from 'js-base64'; -import nacl from 'tweetnacl'; +import type { Transaction } from '@solana/web3.js'; +import { fetchInteraction, isTransactionResponse } from './fetchInteraction.js'; /** * Thrown when a transaction response can't be fetched. @@ -27,50 +25,11 @@ export async function fetchTransaction( link: string | URL, { commitment }: { commitment?: Commitment } = {} ): Promise { - const response = await fetch(String(link), { - method: 'POST', - mode: 'cors', - cache: 'no-cache', - credentials: 'omit', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ account }), - }); + const response = await fetchInteraction(connection, account, link, { commitment }); - const json = await response.json(); - if (!json?.transaction) throw new FetchTransactionError('missing transaction'); - if (typeof json.transaction !== 'string') throw new FetchTransactionError('invalid transaction'); - - const transaction = Transaction.from(toUint8Array(json.transaction)); - const { signatures, feePayer, recentBlockhash } = transaction; - - if (signatures.length) { - if (!feePayer) throw new FetchTransactionError('missing fee payer'); - if (!feePayer.equals(signatures[0].publicKey)) throw new FetchTransactionError('invalid fee payer'); - if (!recentBlockhash) throw new FetchTransactionError('missing recent blockhash'); - - // A valid signature for everything except `account` must be provided. - const message = transaction.serializeMessage(); - for (const { signature, publicKey } of signatures) { - if (signature) { - if (!nacl.sign.detached.verify(message, signature, publicKey.toBuffer())) - throw new FetchTransactionError('invalid signature'); - } else if (publicKey.equals(account)) { - // If the only signature expected is for `account`, ignore the recent blockhash in the transaction. - if (signatures.length === 1) { - transaction.recentBlockhash = (await connection.getRecentBlockhash(commitment)).blockhash; - } - } else { - throw new FetchTransactionError('missing signature'); - } - } - } else { - // Ignore the fee payer and recent blockhash in the transaction and initialize them. - transaction.feePayer = account; - transaction.recentBlockhash = (await connection.getRecentBlockhash(commitment)).blockhash; + if (!isTransactionResponse(response)) { + throw new FetchTransactionError('invalid response'); } - return transaction; + return response.transaction; } diff --git a/core/src/index.ts b/core/src/index.ts index cac676be..a92b175d 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -2,8 +2,10 @@ export * from './constants.js'; export * from './createQR.js'; export * from './createTransfer.js'; export * from './encodeURL.js'; +export * from './fetchInteraction.js'; export * from './fetchTransaction.js'; export * from './findReference.js'; export * from './parseURL.js'; +export * from './sendSignature.js'; export * from './types.js'; export * from './validateTransfer.js'; diff --git a/core/src/parseURL.ts b/core/src/parseURL.ts index 1a5dc8a0..9a686e0e 100644 --- a/core/src/parseURL.ts +++ b/core/src/parseURL.ts @@ -4,9 +4,9 @@ import { HTTPS_PROTOCOL, SOLANA_PROTOCOL } from './constants.js'; import type { Amount, Label, Link, Memo, Message, Recipient, Reference, SPLToken } from './types.js'; /** - * A Solana Pay transaction request URL. + * A Solana Pay interactive request URL. */ -export interface TransactionRequestURL { +export interface InteractiveRequestURL { /** `link` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#link). */ link: Link; /** `label` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#label-1). */ @@ -49,7 +49,7 @@ export class ParseURLError extends Error { * * @throws {ParseURLError} */ -export function parseURL(url: string | URL): TransactionRequestURL | TransferRequestURL { +export function parseURL(url: string | URL): InteractiveRequestURL | TransferRequestURL { if (typeof url === 'string') { if (url.length > 2048) throw new ParseURLError('length invalid'); url = new URL(url); @@ -58,10 +58,10 @@ export function parseURL(url: string | URL): TransactionRequestURL | TransferReq if (url.protocol !== SOLANA_PROTOCOL) throw new ParseURLError('protocol invalid'); if (!url.pathname) throw new ParseURLError('pathname missing'); - return /[:%]/.test(url.pathname) ? parseTransactionRequestURL(url) : parseTransferRequestURL(url); + return /[:%]/.test(url.pathname) ? parseInteractiveRequestURL(url) : parseTransferRequestURL(url); } -function parseTransactionRequestURL({ pathname, searchParams }: URL): TransactionRequestURL { +function parseInteractiveRequestURL({ pathname, searchParams }: URL): InteractiveRequestURL { const link = new URL(decodeURIComponent(pathname)); if (link.protocol !== HTTPS_PROTOCOL) throw new ParseURLError('link invalid'); diff --git a/core/src/sendSignature.ts b/core/src/sendSignature.ts new file mode 100644 index 00000000..647ef557 --- /dev/null +++ b/core/src/sendSignature.ts @@ -0,0 +1,54 @@ +import type { PublicKey } from '@solana/web3.js'; +import fetch from 'cross-fetch'; + +/** + * Thrown when response is invalid + */ +export class SendSignatureError extends Error { + name = 'SendSignatureError'; +} + +export type SendSignatureResponse = { + success?: boolean; +}; + +/** + * Send the results of a Solana Pay sign-message request to the server. + * + * @param account - Account that signed the data + * @param signature - The signature from signing the data. + * @param state - MAC value that was sent by the server during the POST request. + * @param link - `link` in the [Solana Pay spec](https://github.com/solana-labs/solana-pay/blob/master/SPEC.md#link). + * + * @throws {SendSignatureError} + */ +export async function sendSignature( + account: PublicKey, + state: string, + signature: string, + link: string | URL +): Promise { + const response = await fetch(String(link), { + method: 'PUT', + mode: 'cors', + cache: 'no-cache', + credentials: 'omit', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + account, + signature, + state, + }), + }); + + const json: SendSignatureResponse = await response.json(); + + if (typeof json.success !== 'boolean') { + throw new SendSignatureError('invalid response'); + } + + return json.success; +}