Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to the Sorare GraphQL API will be documented in this file. W

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## 2026-08-19

Documentation only, no API change.

The marketplace sections of the README described the `AuthorizationRequest` flow in StarkEx terms only, which made the Solana and Base examples added on 2025-11-04 hard to find when building offers. The README now states that `prepareBid`, `prepareOffer` and `prepareAcceptOffer` return different authorization request types depending on the asset and the payment rail, and lists them in a table mapping each type to its example and its approval field. The "Bidding on auction", "Creating offers" and "Accepting offers" sections point at it.

We also documented how to obtain the Solana key pair used to sign Solana authorization requests: it is derived from the Sorare (Ethereum) private key you export from your wallet, using SLIP-0010 on the standard Solana path `m/44'/501'/0'/0'`. A new example, `solanaKeyPair.js`, performs that derivation; the derived address is the `senderAddress` of the authorization request, which is the quickest way to check it. Note that `@sorare/crypto` supports StarkEx only and cannot sign Solana or Base requests.

`solanaTokenTransfer.js` now builds its `solanaTokenTransferApproval` (it previously showed `solanaBankTransferApproval` by mistake) and derives its signing key rather than assuming you already hold one.

## 2025-11-04

Starting this Thursday some tokens will migrate to Solana and it will be possible to pay using SOL. We've added three examples to help you build approvals if you receive Solana or Base authorization requests from the `prepareBid`, `prepareOffer` and `prepareAcceptOffer` mutations:
Expand Down
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,78 @@ Every operation that involves card or money transfer must be signed with your St

To sign with your Starkware _private key_ in JavaScript, we recommend using the JavaScript package [`@sorare/crypto`](https://github.com/sorare/crypto).

### Authorization request types

The `prepareBid`, `prepareOffer` and `prepareAcceptOffer` mutations return **different types of `AuthorizationRequest` depending on the asset and on the payment rail**. A card that lives on Solana produces a Solana request; a payment in SOL produces another; a payment in ETH on Base produces another still. You must branch on the `__typename` of each returned request and build the matching approval — there is no single signing routine that covers them all.

| `AuthorizationRequest` type | Used for | Example | Approval field |
| --- | --- | --- | --- |
| `StarkexTransferAuthorizationRequest` | Legacy StarkEx card & fund transfers | [authorizations.js](./examples/authorizations.js) | `starkexTransferApproval` |
| `StarkexLimitOrderAuthorizationRequest` | Legacy StarkEx limit orders | [authorizations.js](./examples/authorizations.js) | `starkexLimitOrderApproval` |
| `MangopayWalletTransferAuthorizationRequest` | Fiat wallet payments | [authorizations.js](./examples/authorizations.js) | `mangopayWalletTransferApproval` |
| `SolanaTokenTransferAuthorizationRequest` | Card (NFT) transfer on Solana | [solanaTokenTransfer.js](./examples/solanaTokenTransfer.js) | `solanaTokenTransferApproval` |
| `SolanaBankTransferAuthorizationRequest` | Payment in SOL | [solanaBankTransfer.js](./examples/solanaBankTransfer.js) | `solanaBankTransferApproval` |
| `EthereumBankTransferAuthorizationRequest` | Payment in ETH on Base | [baseBankTransfer.js](./examples/baseBankTransfer.js) | `ethereumBankTransferApproval` |

Every approval is submitted alongside the `fingerprint` of the request it answers.

Note that [`@sorare/crypto`](https://github.com/sorare/crypto) **supports StarkEx only** — it contains a single StarkEx signature implementation and does not help for Solana or Base. `buildApprovals` in [authorizations.js](./examples/authorizations.js) is likewise StarkEx-only. For Solana requests, sign with `@solana/kit` as shown in the examples above; for Base requests, sign with `viem`.

Player Cards are minted on Solana as [Metaplex Bubblegum v2](https://developers.metaplex.com/bubblegum-v2) compressed NFTs and are moved by Sorare's Transfer Proxy program. See [web3/README.md](./web3/README.md) for the programs, contracts and collections involved.

### Signing Solana authorization requests

Solana requests are **not** signed with your Starkware private key. They are signed with your Solana key pair, which is derived from the Sorare (Ethereum) private key you export from your wallet:

- SLIP-0010 HD derivation, using the Ethereum private key bytes as the master seed
- derivation path `m/44'/501'/0'/0'` (the standard Solana path)
- an ed25519 key pair built from the derived private key bytes

A working JavaScript code sample is available in [examples/solanaKeyPair.js](./examples/solanaKeyPair.js).

The address of the derived key pair is the `senderAddress` of the authorization request. **Checking the derived address against `senderAddress` is the fastest way to confirm your derivation** before you start debugging signatures.

Once you hold the key pair, signing a `SolanaTokenTransferAuthorizationRequest` means building this exact message from the request:

```js
const message = [
'TRANSFER',
transferProxyProgramAddress,
merkleTreeAddress,
leafIndex.toString(),
nonce,
expirationTimestamp.toString(),
receiverAddress,
'0x',
originator,
].join(':');
```

then UTF-8 encoding it, hashing it with SHA-256, signing the resulting 32-byte hash with ed25519, and Base58-encoding the signature.

Three things are easy to get wrong here, and each of them produces a well-formed signature that is silently and always rejected:

- you sign the **SHA-256 hash**, not the message string
- `assetId` is **not** part of the signed message — the card is identified on chain by `merkleTreeAddress` and `leafIndex` — even though `assetId` is returned in the request
- `senderAddress` is **not** part of the signed message either, since it is implied by the signing key, while `transferProxyProgramAddress` and `originator` **are**

The `'0x'` entry is a literal empty data field, not a placeholder to substitute.

The resulting approval has exactly three fields. `nonce` and `expirationTimestamp` are echoed back unchanged from the request, because both are part of the signed message:

```js
const approval = {
fingerprint: solanaTokenTransferAuthorizationRequest.fingerprint,
solanaTokenTransferApproval: {
signature, // Base58 string
nonce, // String holding a uint32
expirationTimestamp, // Int, unix seconds
},
};
```

A working JavaScript code sample is available in [examples/solanaTokenTransfer.js](./examples/solanaTokenTransfer.js).

### Listing auctions

To list the latest auctions, you can use the following query:
Expand Down Expand Up @@ -528,6 +600,8 @@ ${authorizationRequestFragment}

3. Sign all `AuthorizationRequest` objects and build the `bidInput` argument. `buildApprovals` is defined in [authorizations.js](./examples/authorizations.js).

`prepareBid` returns different types of `AuthorizationRequest` depending on the payment rail, and `buildApprovals` only handles the StarkEx and Mangopay ones. Branch on the `__typename` of each request and see [Authorization request types](#authorization-request-types) for the full list — in particular [solanaBankTransfer.js](./examples/solanaBankTransfer.js) if you pay in SOL, and [baseBankTransfer.js](./examples/baseBankTransfer.js) if you pay in ETH on Base.

```js
const approvals = buildApprovals(starkPrivateKey, authorizations);

Expand Down Expand Up @@ -629,6 +703,8 @@ ${authorizationRequestFragment}

3. Sign all `AuthorizationRequest` objects and build the `createSingleSaleOfferInput` or `createDirectOfferInput` argument. `buildApprovals` is defined in [authorizations.js](./examples/authorizations.js).

`prepareOffer` returns different types of `AuthorizationRequest` depending on where the card lives and on the payment rail, and `buildApprovals` only handles the StarkEx and Mangopay ones. Branch on the `__typename` of each request and see [Authorization request types](#authorization-request-types) for the full list. If the card you are sending is on Solana — which is the case for both `SINGLE_SALE_OFFER` and `DIRECT_OFFER` on migrated cards — you will get a `SolanaTokenTransferAuthorizationRequest`, signed as described in [Signing Solana authorization requests](#signing-solana-authorization-requests) and shown in [solanaTokenTransfer.js](./examples/solanaTokenTransfer.js). Payments produce a `SolanaBankTransferAuthorizationRequest` ([solanaBankTransfer.js](./examples/solanaBankTransfer.js)) or an `EthereumBankTransferAuthorizationRequest` ([baseBankTransfer.js](./examples/baseBankTransfer.js)).

```js
const approvals = buildApprovals(starkPrivateKey, authorizations);

Expand Down Expand Up @@ -748,6 +824,8 @@ ${authorizationRequestFragment}

4. Sign all `AuthorizationRequest` objects and build the `acceptOfferInput` argument. `buildApprovals` is defined in [authorizations.js](./examples/authorizations.js).

`prepareAcceptOffer` returns different types of `AuthorizationRequest` depending on where the card lives and on the payment rail, and `buildApprovals` only handles the StarkEx and Mangopay ones. Branch on the `__typename` of each request and see [Authorization request types](#authorization-request-types) for the full list. Accepting a direct offer where you send a card on Solana yields a `SolanaTokenTransferAuthorizationRequest` ([solanaTokenTransfer.js](./examples/solanaTokenTransfer.js), and [Signing Solana authorization requests](#signing-solana-authorization-requests)); paying yields a `SolanaBankTransferAuthorizationRequest` ([solanaBankTransfer.js](./examples/solanaBankTransfer.js)) or an `EthereumBankTransferAuthorizationRequest` ([baseBankTransfer.js](./examples/baseBankTransfer.js)).

```js
const approvals = buildApprovals(starkPrivateKey, authorizations);

Expand Down
1 change: 1 addition & 0 deletions examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"crypto": "^1.0.1",
"graphql": "^16.2.0",
"graphql-request": "^3.7.0",
"micro-key-producer": "^0.10.0",
"viem": "^2.38.6",
"yargs": "^17.3.0"
}
Expand Down
7 changes: 7 additions & 0 deletions examples/solanaBankTransfer.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
// Signing a `SolanaBankTransferAuthorizationRequest`, the authorization request
// you get when paying in SOL.
//
// This example starts from a Solana private key you already hold. See
// solanaKeyPair.js for how to derive that key pair from the Sorare private key
// exported from your wallet.

const {
createKeyPairFromBytes,
createSignerFromKeyPair,
Expand Down
55 changes: 55 additions & 0 deletions examples/solanaKeyPair.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Deriving your Solana key pair from your Sorare private key.
//
// Solana authorization requests (`SolanaTokenTransferAuthorizationRequest` and
// `SolanaBankTransferAuthorizationRequest`) must be signed with your Solana key
// pair. You never export that key pair from sorare.com directly: it is derived
// deterministically from the Sorare (Ethereum) private key you export from your
// wallet (see the "Examples" section of the top-level README).
//
// The derivation is standard SLIP-0010:
// - the Ethereum private key bytes are used as the HD master seed
// - the derivation path is m/44'/501'/0'/0' (the standard Solana path)
// - the derived private key bytes give an ed25519 key pair
//
// The address of the derived key pair is the `senderAddress` returned in the
// authorization request. Comparing the two is the fastest way to confirm your
// derivation is correct before you start debugging signatures.

const {
createKeyPairFromPrivateKeyBytes,
createSignerFromKeyPair,
} = require('@solana/kit');
const { HDKey } = require('micro-key-producer/slip10.js');

const SOLANA_DERIVATION_PATH = "m/44'/501'/0'/0'";

// `ethereumPrivateKey` is the private key exported from sorare.com, with or
// without the leading `0x`.
const deriveSolanaSigner = async ethereumPrivateKey => {
const seed = Buffer.from(ethereumPrivateKey.replace(/^0x/, ''), 'hex');
const { privateKey: derivedPrivateKeyBytes } = HDKey.fromMasterSeed(
seed
).derive(SOLANA_DERIVATION_PATH);

const keyPair = await createKeyPairFromPrivateKeyBytes(
derivedPrivateKeyBytes
);

return createSignerFromKeyPair(keyPair);
};

module.exports = { deriveSolanaSigner, SOLANA_DERIVATION_PATH };

// Running this file prints the derived Solana address. Check it against the
// `senderAddress` of the authorization request you are trying to sign: if they
// differ, the problem is the derivation, not the signature.
if (require.main === module) {
const ethereumPrivateKey =
'0xa9405b77d085276e4b6e35cf494e83f0533d4751fc13e2fdceb6229330ef5146';

deriveSolanaSigner(ethereumPrivateKey).then(signer => {
// Prints 8ixw6XQW2tuZhc1xgbhh6bq6YvL5K5nXLsN9LjrzMrxq, which is the
// `senderAddress` of the request in solanaBankTransfer.js.
console.log(signer.address);
});
}
91 changes: 61 additions & 30 deletions examples/solanaTokenTransfer.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,61 @@
// Signing a `SolanaTokenTransferAuthorizationRequest`.
//
// This is the authorization request you get from `prepareOffer` (SINGLE_SALE_OFFER
// and DIRECT_OFFER) and from `prepareAcceptOffer` when the card you are sending
// lives on Solana. It authorises the Transfer Proxy program to move one Player
// Card, and it is signed with your Solana key pair — not with your Starkware key
// and not with `@sorare/crypto`, which only supports StarkEx.
//
// See solanaKeyPair.js for where the Solana key pair comes from.

const {
createKeyPairFromBytes,
createSignerFromKeyPair,
createSignableMessage,
getBase58Encoder,
getBase58Decoder,
} = require('@solana/kit');
const { deriveSolanaSigner } = require('./solanaKeyPair');

const privateKey = '2KGrum1o5ZudshxeUDjKesA5hvyGvHeqaUet6BMUhb8zi7eCT9ifgCBFYWYTn2o8oM5js2FCs2aHj6ABDLfP8vaA'
// The Sorare private key exported from your wallet on sorare.com.
const ethereumPrivateKey =
'0xa9405b77d085276e4b6e35cf494e83f0533d4751fc13e2fdceb6229330ef5146';

const solanaTokenTransferAuthorizationRequest = {
__typename: "AuthorizationRequest",
fingerprint: "d4d0f9558d2f58cad7ebbed5a92edc49",
__typename: 'AuthorizationRequest',
fingerprint: 'd4d0f9558d2f58cad7ebbed5a92edc49',
request: {
__typename: "SolanaTokenTransferAuthorizationRequest",
leafIndex: 5,
merkleTreeAddress: "CS7kYFjkSW9iPmCZpmNv5jwyE9FmLzR95ag2bpwtM8uF",
originator: "Dv8A8XKBz5QARFKZ5Kewdk8myCDcne9wiD7ULTanHKU",
receiverAddress: "cZq5d4nCqUJoysDh49TPRBSXgFx5dsP9Ho4PVJgYEDY",
expirationTimestamp: 1763482762,
nonce: "3",
transferProxyProgramAddress: "Gz9o1yxV5kVfyC53fFu7StTVeetPZWa2sohzvxJiLxMP"
}
}
__typename: 'SolanaTokenTransferAuthorizationRequest',
assetId:
'0x04002c8934c7fadd5a832a693b8a9d295a915fb1d0c2250d824ae18e7c5bba7a',
leafIndex: 5,
merkleTreeAddress: 'CS7kYFjkSW9iPmCZpmNv5jwyE9FmLzR95ag2bpwtM8uF',
originator: 'Dv8A8XKBz5QARFKZ5Kewdk8myCDcne9wiD7ULTanHKU',
receiverAddress: 'cZq5d4nCqUJoysDh49TPRBSXgFx5dsP9Ho4PVJgYEDY',
senderAddress: '8ixw6XQW2tuZhc1xgbhh6bq6YvL5K5nXLsN9LjrzMrxq',
expirationTimestamp: 1763482762,
nonce: '3',
transferProxyProgramAddress: 'Gz9o1yxV5kVfyC53fFu7StTVeetPZWa2sohzvxJiLxMP',
},
};

const {
leafIndex,
merkleTreeAddress,
originator,
receiverAddress,
senderAddress,
expirationTimestamp,
nonce,
transferProxyProgramAddress,
} = solanaTokenTransferAuthorizationRequest.request;

// Note what is, and is not, part of the signed message:
// - `assetId` is NOT signed. The card is identified on chain by
// `merkleTreeAddress` + `leafIndex`, even though `assetId` is returned in the
// request so you can tell which card it is.
// - `senderAddress` is NOT signed either: it is implied by the signing key.
// - `transferProxyProgramAddress` and `originator` ARE signed.
// - `'0x'` is a literal empty data field. It is not a placeholder to substitute.
// Getting any of this wrong produces a well-formed signature that is always
// rejected, with no clue as to why.
const message = [
'TRANSFER',
transferProxyProgramAddress,
Expand All @@ -49,27 +72,35 @@ const textEncoder = new TextEncoder();
const messageBytes = textEncoder.encode(message);

async function signRequest() {
const secretKeyBytes = getBase58Encoder().encode(privateKey)
const keyPair = await createKeyPairFromBytes(secretKeyBytes)
const signer = await createSignerFromKeyPair(
keyPair
);
const signer = await deriveSolanaSigner(ethereumPrivateKey);

// The derived address is the `senderAddress` of the request. If this throws,
// your derivation is wrong and there is no point debugging the signature.
if (signer.address !== senderAddress) {
throw new Error(
`Derived ${signer.address} but the request is for ${senderAddress}`
);
}

// You sign the SHA-256 hash of the message, not the message itself.
const messageHash = await crypto.subtle.digest('SHA-256', messageBytes);
const mess = createSignableMessage(new Uint8Array(messageHash));
const [ret] = await signer.signMessages([mess]);
const signature = getBase58Decoder().decode(ret['BvJrHm3rBx9ddmz4dzK4Jp8ibC8WSfYP5qipE7M1CbDx']);
const signableMessage = createSignableMessage(new Uint8Array(messageHash));
const [signatures] = await signer.signMessages([signableMessage]);
const signature = getBase58Decoder().decode(signatures[signer.address]);

// `nonce` and `expirationTimestamp` are echoed back unchanged from the
// request: both are part of the signed message, so any other value invalidates
// the signature.
const approval = {
fingerprint: solanaTokenTransferAuthorizationRequest.fingerprint,
solanaBankTransferApproval: {
signature,
expirationTimestamp,
nonce,
solanaTokenTransferApproval: {
signature, // Base58 string
nonce, // String holding a uint32
expirationTimestamp, // Int, unix seconds
},
};

console.log(approval);
}

// expected signature: 4aR7f1eaVbBfxQze5vgktZ18RP1tUYB28yiaRTBJDPgtpe4kcvAcMq3QM6C8HPTzVTS9RYxj9XdrwmGnpU52Fc5n
signRequest();
signRequest();
Loading