Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `signProofOfOwnership` client request for silent proof-of-ownership signing (SEP-0053) ([#186](https://github.com/MetaMask/internal-snaps/pull/186))
- Add `TrustlineExceedLimitException` for send simulation when a payment would exceed the destination trustline limit (previously a generic `TransactionValidationException`) ([#185](https://github.com/MetaMask/internal-snaps/pull/185))

### Changed
Expand Down
1 change: 1 addition & 0 deletions packages/stellar-wallet-snap/docs/use-cases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ High-level flows for the Stellar Wallet Snap. Each doc focuses on **handlers**,
| Quote swap / bridge fee | `computeFee` | [computeFee.md](./client-request/computeFee.md) |
| Sign & submit swap / bridge | `signAndSendTransaction` | [signAndSendTransaction.md](./client-request/signAndSendTransaction.md) |
| Change trustline (opt-in / opt-out) | `changeTrustOpt` | [changeTrustOpt.md](./client-request/changeTrustOpt.md) |
| Silent proof-of-ownership signing | `signProofOfOwnership` | [signProofOfOwnership.md](./client-request/signProofOfOwnership.md) |

## Cronjob (`onCronjob`)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Use case: `signProofOfOwnership`

Silently signs a proof-of-ownership message so `@metamask/profile-metrics-controller` can prove the user controls a Stellar address.

| | |
| ---------- | --------------------------------------------------------------------------------------------------------------- |
| **Entry** | `onClientRequest` → `ClientRequestHandler` → `SignProofOfOwnershipHandler` |
| **Method** | `signProofOfOwnership` (`ClientRequestMethod.SignProofOfOwnership`) |
| **Source** | [`handlers/clientRequest/signProofOfOwnership.ts`](../../../src/handlers/clientRequest/signProofOfOwnership.ts) |

This is a **silent sign** — there is no confirmation dialog. That is intentional: the MetaMask client needs an ownership proof without interrupting the user. The method is scoped so it cannot be used as a general sign-message bypass:

1. SIP-31 `onClientRequest` is only callable by the MetaMask client.
2. The plaintext must be `metamask:proof-of-ownership:{nonce}:{address}`, and the embedded address must match the signing account.
3. Signing uses [SEP-0053](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md) (`Wallet.signMessage`).

## Request / response (shape)

**Request params**

- `accountId` — keyring account UUID
- `message` — plaintext `metamask:proof-of-ownership:{nonce}:{address}` (nonce may contain colons; the address is the last `:`-separated field)
- `nonce`, `address` — coerced from `message` internally (clients do not send these)

**Response**

- `{ signature }` — standard base64 of the 64-byte ed25519 signature (SEP-0053)

## Participants

| Component | Path | Role in this flow |
| ----------------------------- | ------------------------ | ---------------------------------------------------- |
| `ClientRequestHandler` | `handlers/clientRequest` | Routes `signProofOfOwnership` to the handler |
| `SignProofOfOwnershipHandler` | `handlers/clientRequest` | Validates message, resolves wallet, signs |
| `AccountResolver` | `handlers/` | Loads keyring account + wallet (no on-chain account) |
| `Wallet` | `services/wallet` | SEP-0053 `signMessage` |

## Step-by-step

1. **Route** — `onClientRequest` dispatches to `SignProofOfOwnershipHandler`.
2. **Validate** — Request must match `SignProofOfOwnershipJsonRpcRequestStruct` (prefix, nonce, Stellar address). `nonce` and `address` are coerced from `message`.
3. **Resolve** — `BaseClientRequestHandler` loads keyring account and wallet only (`RESOLVE_ACCOUNT_KEYRING_AND_WALLET`). The destination account does not need to be activated on-chain.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Replace BaseClientRequestHandler with SignProofOfOwnershipHandler (or AccountResolver). That class is the one that actually calls resolveAccount with RESOLVE_ACCOUNT_KEYRING_AND_WALLET.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yup the doc is not update, let me update it

4. **Bind** — The address in the message must equal the signing account address.
5. **Sign** — `Wallet.signMessage(message)` (SEP-0053, base64).

## Sequence (happy path)

```mermaid
sequenceDiagram
participant Client
participant Handler as SignProofOfOwnershipHandler
participant Resolver as AccountResolver
participant Wallet

Client->>Handler: signProofOfOwnership { accountId, message }
Note over Handler: validate coerces nonce + address from message
Handler->>Resolver: resolve keyring account + wallet
Resolver-->>Handler: account, wallet
Handler->>Handler: message address == account.address
Handler->>Wallet: signMessage (SEP-0053, base64)
Wallet-->>Handler: signature
Handler-->>Client: { signature }
```
2 changes: 1 addition & 1 deletion packages/stellar-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "SszhtaffKOI/qFUP4XYJU0wGVFGwz/dQAL7pZEONfyI=",
"shasum": "I2mbgubtMTDqkgjglt3o7ji1GuNVFYlZ0L0aZNK4Jdw=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
7 changes: 7 additions & 0 deletions packages/stellar-wallet-snap/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ConfirmSendHandler } from './handlers/clientRequest/confirmSend';
import { OnAddressInputHandler } from './handlers/clientRequest/onAddressInput';
import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput';
import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction';
import { SignProofOfOwnershipHandler } from './handlers/clientRequest/signProofOfOwnership';
import type { ICronjobRequestHandler } from './handlers/cronjob/api';
import { BackgroundEventMethod } from './handlers/cronjob/api';
import {
Expand Down Expand Up @@ -291,6 +292,11 @@ const computeFeeHandler = new ComputeFeeHandler({
transactionService,
});

const signProofOfOwnershipHandler = new SignProofOfOwnershipHandler({
logger,
accountResolver,
});

const clientRequestMethodHandlers: Record<
ClientRequestMethod,
IClientRequestHandler
Expand All @@ -301,6 +307,7 @@ const clientRequestMethodHandlers: Record<
[ClientRequestMethod.ConfirmSend]: confirmSendHandler,
[ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler,
[ClientRequestMethod.ComputeFee]: computeFeeHandler,
[ClientRequestMethod.SignProofOfOwnership]: signProofOfOwnershipHandler,
};

const clientRequestHandler = new ClientRequestHandler({
Expand Down
146 changes: 146 additions & 0 deletions packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
ConfirmSendJsonRpcResponseStruct,
SignAndSendTransactionJsonRpcRequestStruct,
SignAndSendTransactionJsonRpcResponseStruct,
SignProofOfOwnershipJsonRpcRequestStruct,
SignProofOfOwnershipJsonRpcResponseStruct,
} from './api';

const accountId = '11111111-1111-4111-8111-111111111111';
Expand Down Expand Up @@ -918,3 +920,147 @@ describe('ConfirmSendJsonRpcResponseStruct', () => {
);
});
});

describe('SignProofOfOwnershipJsonRpcRequestStruct', () => {
const nonce = 'a1b2c3d4e5f6789012345678';

it.each([
{
message: `metamask:proof-of-ownership:${nonce}:${stellarAddress}`,
nonce,
address: stellarAddress,
},
{
message: `metamask:proof-of-ownership:abc-DEF_123:${stellarAddress}`,
nonce: 'abc-DEF_123',
address: stellarAddress,
},
{
message: `metamask:proof-of-ownership:ns:abc:123:${stellarAddress}`,
nonce: 'ns:abc:123',
address: stellarAddress,
},
])(
'accepts a valid signProofOfOwnership request: "$message"',
({ message, nonce: expectedNonce, address }) => {
const result = create(
{
jsonrpc: '2.0',
id: 1,
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId, message },
},
SignProofOfOwnershipJsonRpcRequestStruct,
);

expect(result.params).toStrictEqual({
accountId,
message,
nonce: expectedNonce,
address,
});
},
);

it.each([
{
method: ClientRequestMethod.ConfirmSend,
params: {
accountId,
message: `metamask:proof-of-ownership:${nonce}:${stellarAddress}`,
},
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId },
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId, message: `rewards,${stellarAddress},123` },
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: {
accountId,
message: `metamask:proof:${nonce}:${stellarAddress}`,
},
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: {
accountId,
message: `Metamask:proof-of-ownership:${nonce}:${stellarAddress}`,
},
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId, message: `${nonce}:${stellarAddress}` },
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId, message: '' },
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId, message: `metamask:proof-of-ownership:${nonce}` },
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: {
accountId,
message: `metamask:proof-of-ownership::${stellarAddress}`,
},
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: { accountId, message: `metamask:proof-of-ownership:${nonce}:` },
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: {
accountId,
message: `metamask:proof-of-ownership:${nonce}:not-a-stellar-address`,
},
},
{
method: ClientRequestMethod.SignProofOfOwnership,
params: {
accountId,
message: `metamask:proof-of-ownership:${nonce}:0x1234567890abcdef1234567890abcdef12345678`,
},
},
])(
'rejects an invalid signProofOfOwnership request',
({ method, params }) => {
expect(() =>
assert(
{ jsonrpc: '2.0', id: 1, method, params },
SignProofOfOwnershipJsonRpcRequestStruct,
),
).toThrow(StructError);
},
);
});

describe('SignProofOfOwnershipJsonRpcResponseStruct', () => {
it('accepts a standard base64 signature', () => {
expect(() =>
assert(
{
signature:
'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==',
},
SignProofOfOwnershipJsonRpcResponseStruct,
),
).not.toThrow();
});

it.each([{ signature: 'not!!!valid-base64' }, { signature: '' }, {}])(
'rejects an invalid signProofOfOwnership response',
(response) => {
expect(() =>
assert(response, SignProofOfOwnershipJsonRpcResponseStruct),
).toThrow(StructError);
},
);
});
89 changes: 88 additions & 1 deletion packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
coerce,
} from '@metamask/superstruct';
import type { JsonRpcRequest } from '@metamask/utils';
import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils';
import {
base64,
CaipAssetTypeStruct,
parseCaipAssetType,
} from '@metamask/utils';

import {
JsonRpcRequestStruct,
Expand All @@ -36,6 +40,7 @@ import {
SwapTransactionXdrStruct,
} from '../../api';
import { isSep41Id } from '../../utils';
import { parseProofOfOwnershipMessage } from './utils';

/**
* Enum for the client request method.
Expand All @@ -48,6 +53,11 @@ export enum ClientRequestMethod {
// Standard multichain workflow for bridge
SignAndSendTransaction = 'signAndSendTransaction',
ComputeFee = 'computeFee',
/**
* Silent proof-of-ownership signing for `@metamask/profile-metrics-controller`.
* SIP-31 client-only.
*/
Comment thread
Copilot marked this conversation as resolved.
SignProofOfOwnership = 'signProofOfOwnership',
/** -------------------------------- Stellar Specific -------------------------------- */
ChangeTrustOpt = 'changeTrustOpt',
}
Expand Down Expand Up @@ -375,6 +385,69 @@ export const ComputeFeeJsonRpcResponseStruct = array(
}),
);

/**
* Validates that a plaintext message follows the proof-of-ownership format:
* `'metamask:proof-of-ownership:{nonce}:{address}'`.
*/
export const ProofOfOwnershipMessageStruct = refine(
string(),
'ProofOfOwnershipMessage',
(value: string) => {
try {
parseProofOfOwnershipMessage(value);
return true;
} catch (error) {
return error instanceof Error
? error.message
: 'Invalid proof-of-ownership message';
}
},
);

/**
* Validation struct for the signProofOfOwnership JSON-RPC request.
* Coerces `nonce` and `address` from `message` (clients send only accountId + message).
*/
export const SignProofOfOwnershipJsonRpcRequestStruct = coerce(
assign(
JsonRpcRequestStruct,
object({
method: literal(ClientRequestMethod.SignProofOfOwnership),
params: object({
accountId: UuidStruct,
message: ProofOfOwnershipMessageStruct,
nonce: nonempty(string()),
address: StellarAddressStruct,
}),
}),
),
assign(
JsonRpcRequestStruct,
object({
method: literal(ClientRequestMethod.SignProofOfOwnership),
params: object({
accountId: UuidStruct,
message: ProofOfOwnershipMessageStruct,
}),
}),
),
(request) => ({
...request,
params: {
...request.params,
...parseProofOfOwnershipMessage(request.params.message),
},
}),
);

/**
* Validation struct for the signProofOfOwnership JSON-RPC response.
* Standard base64 of the 64-byte ed25519 signature (SEP-0053).
*/
export const SignProofOfOwnershipJsonRpcResponseStruct = object({
signature: nonempty(base64(string())),
});

/**
* A JSON-RPC request with an account resolve parameter.
*/
Expand Down Expand Up @@ -466,3 +539,17 @@ export type ComputeFeeJsonRpcRequest = Infer<
export type ComputeFeeJsonRpcResponse = Infer<
typeof ComputeFeeJsonRpcResponseStruct
>;

/**
* Type for the signProofOfOwnership JSON-RPC request.
*/
export type SignProofOfOwnershipJsonRpcRequest = Infer<
typeof SignProofOfOwnershipJsonRpcRequestStruct
>;

/**
* Type for the signProofOfOwnership JSON-RPC response.
*/
export type SignProofOfOwnershipJsonRpcResponse = Infer<
typeof SignProofOfOwnershipJsonRpcResponseStruct
>;
Loading