Skip to content

feat: add verifyOffchainMessage helper for signed v1 offchain messages - #1725

Closed
amilz wants to merge 1 commit into
anza-xyz:mainfrom
amilz:feat/ocms-verification-helper
Closed

feat: add verifyOffchainMessage helper for signed v1 offchain messages#1725
amilz wants to merge 1 commit into
anza-xyz:mainfrom
amilz:feat/ocms-verification-helper

Conversation

@amilz

@amilz amilz commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Adds a verifyOffchainMessage helper to @solana/offchain-messages that verifies a signed version 1 offchain message returned by an untrusted signer (eg. a wallet) in a single call.

When you ask a wallet to sign an offchain message, it hands back the bytes it signed plus a signature. Verifying the signature alone is not enough — a compromised signer could return a perfectly valid signature over data that has nothing to do with what you asked it to sign. From the message and requiredSigners you requested, verifyOffchainMessage reconstructs the version 1 message you expected the signer to sign, asserts it matches the bytes the signer reports having signed, then asserts the signature is a valid Ed25519 signature of those bytes by the signer.

This is distinct from the existing verifyOffchainMessageEnvelope, which trusts the envelope's content as-is and has no notion of an expected message to compare against.

  • Only version 1 messages are supported, matching the upcoming solana:signOffchainMessage wallet feature.
  • For the common case of a connected wallet signing a message only it needs to sign, requiredSigners is optional and defaults to [signer].
  • The signer returns one signature per signer, so call this once per signature you collect.
  • Reconstructing the expected bytes via the v1 encoder reuses the spec's structural checks (non-empty, duplicate-free, sorted signers; non-empty content), so a malformed request throws before any comparison.
  • Adds error code SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED (5607018), thrown when the signed bytes don't match the expected message.

Test plan

  • New verifyOffchainMessage test suite covering: happy path, requiredSigners default, content mismatch, short-circuit before signature verification, invalid signature, signer-not-required guard, out-of-order signers, version tampering, truncated bytes, and the structural-validation cases.
  • pnpm test:lint, test:typecheck, test:unit:node, test:unit:browser all pass.

…sages

Adds a `verifyOffchainMessage` helper to `@solana/offchain-messages` that verifies a signed version 1 offchain message returned by an untrusted signer (eg. a wallet) in a single call. Verifying the returned signature alone is insufficient, because a compromised wallet can hand back a valid signature over data unrelated to what the dApp requested. The helper encodes the message the caller expected the signer to sign, asserts that it matches the bytes the signer reports having signed, and then asserts that the signature is a valid Ed25519 signature of those bytes by the signer.

It supports version 1 messages only, matching the v1-only `solana:signOffchainMessage` wallet-standard feature. A new `SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED` error code is thrown when the signed bytes do not match the expected message. Because encoding the expected message already enforces the spec's structural rules (non-empty and duplicate-free required signatories serialized in sorted order, and non-empty content), the helper does not re-implement those checks and instead surfaces the encoder's errors.
@changeset-bot

changeset-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b63dcdb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 47 packages
Name Type
@solana/offchain-messages Minor
@solana/errors Minor
@solana/kit Minor
@solana/signers Minor
@solana/accounts Minor
@solana/addresses Minor
@solana/assertions Minor
@solana/codecs-core Minor
@solana/codecs-data-structures Minor
@solana/codecs-numbers Minor
@solana/codecs-strings Minor
@solana/compat Minor
@solana/fixed-points Minor
@solana/instruction-plans Minor
@solana/instructions Minor
@solana/keys Minor
@solana/options Minor
@solana/program-client-core Minor
@solana/programs Minor
@solana/react Minor
@solana/rpc-api Minor
@solana/rpc-spec Minor
@solana/rpc-subscriptions-channel-websocket Minor
@solana/rpc-subscriptions-spec Minor
@solana/rpc-subscriptions Minor
@solana/rpc-transformers Minor
@solana/rpc-transport-http Minor
@solana/rpc-types Minor
@solana/rpc Minor
@solana/subscribable Minor
@solana/sysvars Minor
@solana/transaction-confirmation Minor
@solana/transaction-messages Minor
@solana/transactions Minor
@solana/wallet-account-signer Minor
@solana/plugin-interfaces Minor
@solana/rpc-graphql Minor
@solana/rpc-parsed-types Minor
@solana/rpc-subscriptions-api Minor
@solana/codecs Minor
@solana/fast-stable-stringify Minor
@solana/functional Minor
@solana/nominal-types Minor
@solana/plugin-core Minor
@solana/promises Minor
@solana/rpc-spec-types Minor
@solana/webcrypto-ed25519-polyfill Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mcintyre94 mcintyre94 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I think this would be a good addition to Kit, various consumers of offchain message signing should be verifying that the message is as they expect.

I think this API is doing too much though, and in particular is tied to the v1 spec such that a hypothetical v2 would probably require a breaking change.

I think the easiest way to handle that is probably to make this v1 specific, and make it the responsibility of apps to call the appropriate version. That would move decoding to the app, but I think reducing the scope of this method would be good.

I'd then restructure it so that it has a signature like:

function assertV1OffchainMessagesEqual(
    receivedMessage: OffchainMessageV1,
    expectedMessage: OffchainMessageV1 // or omit `version`
)

Then it would:

  • check version of receivedMessage, throw if not 1
  • check content equal, throw otherwise
  • check requiredSignatories equal, throw otherwise

I'd use different error codes for each check, with the precise expected/received value that mismatches in context. We should double check we don't consider that sensitive, but I think it'd make sense.

Then the consumer would:

await verifySignature(publicKey, signature, signedMessageBytes) // if it needs to check multiple signatures, the app can do so here
const receivedOffchainMessage = getOffchainMessageDecoder().decode(signedMessageBytes)
if (receivedOffchainMessage.version === 1) {
    assertV1OffchainMessagesEqual(receivedOffchainMessage, myInputOffchainMessage)
} else {
  // throw, not the version the app expected
}

Now if the spec moves on and the dapp starts sending version 2 messages to the wallet and we add a v2 verify function, it just needs to change:

if (receivedOffchainMessage.version === 2) {
    assertV2OffchainMessagesEqual(receivedOffchainMessage, myInputOffchainMessage)
} else {
  // throw, not the version the app expected
}

On another note, thinking about this API in the context of multiple requiredSigners makes the feature as proposed feel quite limited. An app would need to piece together the envelope with multiple signatures itself. We'll be able to hide that in the signer API (as we currently do with signMessage) though.

@github-actions github-actions Bot added the stale label Jun 22, 2026
@mcintyre94 mcintyre94 added the do-not-close Add this tag to exempt an issue/PR from being closed by the stalebot label Jun 29, 2026
mcintyre94 added a commit that referenced this pull request Aug 5, 2026
A wallet asked to sign an offchain message returns the bytes it signed alongside a signature. Verifying that signature proves only that the wallet produced it over those bytes; it says nothing about whether the bytes represent the message the app asked for. A compromised or buggy wallet can return a perfectly valid signature over something else entirely, so an app has to establish both.

`assertOffchainMessageV1Equal` answers the first question and leaves the second to the existing `verifyOffchainMessageEnvelope`. It takes the version 1 message the caller decoded and the version 1 message it expected, and compares the content and the required signatories, giving each kind of mismatch its own error code so that the diagnosis is accurate. Order matters here — asserting before verifying means a wallet that signed the wrong message reports a content mismatch rather than a signature verification failure, which would blame the cryptography for a content problem.

Both decoding and version dispatch stay with the caller, which keeps this function scoped to a single specification version and its parameter type honest about that. Taking the decoded `OffchainMessage` union and checking the version internally would save the caller a branch, but it costs more than it saves: apps get no build-time signal when a new version appears, and it is the irreversible choice, since a parameter can be widened later without breaking callers but never narrowed. As written, an app can switch exhaustively on `version` and have the compiler fail its build the day `OffchainMessageV2` joins the union. What to do about an unexpected version is app policy in any case, and the `solana:signOffchainMessage` feature already discriminates its input on `messageVersion`, so an app always knows which version it asked for.

Required signatories are compared without regard to order, since the specification mandates lexicographic order on the wire while a caller-built message may be in any order. Order is the only thing ignored — the lists are otherwise compared element by element — and both are reported in sorted order in the error context so they can be compared by eye. Message content is deliberately kept out of the error context, as it can carry data better left out of logs and error reporting; its length is reported instead, and nothing is lost diagnostically since the caller holds both messages at the call site.

This reworks the approach explored in draft PR #1725, which combined the content check and signature verification into one call keyed on a raw message string. That shape hard-coded version 1 without naming it, reported every content problem through a single error, and left multi-signer callers re-encoding the expected message once per signature.
@amilz

amilz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

dropping in lieu of #1888

@amilz amilz closed this Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Because there has been no activity on this PR for 14 days since it was merged, it has been automatically locked. Please open a new issue if it requires a follow up.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Aug 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-close Add this tag to exempt an issue/PR from being closed by the stalebot stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants