Skip to content
Draft
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The default server path is intentionally short:
- server-broadcast Permit2 credentials
- client-broadcast transaction-hash credentials
- fee sponsorship
- split payments
- split payments via ordered sequential Permit2 transfers
- replay protection
- instructive RFC 9457-compatible errors
- Session flows:
Expand Down Expand Up @@ -287,8 +287,8 @@ just release-prep
## Draft Caveats

- Charge direct settlement still signs the challenge recipient as the spender because PR 205 does not yet expose a separate spender field.
- Split charge payments use a batch Permit2 extension when more than one transfer leg is needed.
- Session receipts stay `mppx`-compatible in v1. Richer session acceptance state is returned alongside the resource instead of inside the serialized `Payment-Receipt` header.
- Split charge payments use ordered `authorizations[]` and settle sequentially. They are not atomic, and `credentialMode: "hash"` is rejected for split requests.
- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header. Generic `mppx` receipt parsing still drops that field because upstream `mppx` does not include it in the shared receipt schema.
- Session gas sponsorship is out of scope in v1. The payer wallet pays gas for `open` and `topUp`, while the server settlement wallet pays gas for `settle` and `close`.

## License
Expand Down
2 changes: 1 addition & 1 deletion demo/shared/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ export const demoDescriptions = {

export const demoDraftCaveats = [
"Direct settlement signs the recipient as the spender because the draft spec does not yet expose a dedicated spender field.",
"Split payments use the SDK batch Permit2 extension while PR 205 remains open.",
'Split payments use ordered Permit2 "authorizations[]" and settle sequentially. Hash mode stays disabled for split charges because PR 205 still lacks a multi-hash split flow.',
] as const;
8 changes: 6 additions & 2 deletions docs/agent-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,9 @@ export const mppx = Mppx.create({
Notes:

- `charge` defaults to Permit2 credential mode. That is the simplest path when the server settles.
- If the payer must broadcast the charge directly, set `credentialMode: "hash"`. Omit `submissionMode` to use `realtime`, or set it explicitly when you need `sync` or `sendAndWait`.
- If the payer must broadcast the charge directly, set `credentialMode: "hash"` only for unsplit charge requests. Omit `submissionMode` to use `realtime`, or set it explicitly when you need `sync` or `sendAndWait`.
- Split charge requests use ordered Permit2 `authorizations[]` and settle sequentially, primary transfer first.
- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, while generic `mppx` receipt parsing still drops that field.
- `session` needs a `deposit` on the client unless the server challenge already includes `suggestedDeposit`.

## Express Server Recipe
Expand Down Expand Up @@ -576,7 +578,7 @@ The repository includes a working Durable Object store adapter at
## Verification Checklist

1. Confirm the server returns `402` with a payment challenge before the route is paid.
2. Confirm the route returns your resource plus a `Payment-Receipt` header after payment succeeds.
2. Confirm the route returns your resource plus a `Payment-Receipt` header after payment succeeds. If you need `challengeId`, read the raw header JSON instead of relying on generic `mppx` receipt parsing.
3. Confirm the client wallet is connected to the same `chainId` as the challenge.
4. Confirm the payer wallet has MegaETH gas plus the payment token balance.
5. For `charge`, confirm the payer approved Permit2 once.
Expand All @@ -594,6 +596,8 @@ The repository includes a working Durable Object store adapter at
- Set `submissionMode` to `sync` or `sendAndWait` before retrying the broadcast flow.
- Charge hash mode used with server-sponsored gas:
- Switch back to `credentialMode: "permit2"` before retrying because the server requested fee sponsorship.
- Charge hash mode used with splits:
- Switch back to `credentialMode: "permit2"` before retrying because split charges now settle through multiple sequential Permit2 transfers.
- Permit2 allowance missing:
- Approve Permit2 for the payment token before retrying the first charge.
- Session escrow missing:
Expand Down
6 changes: 4 additions & 2 deletions docs/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ The UI runs at `http://localhost:5173`.

- `MEGAETH_SUBMISSION_MODE` feeds directly into the SDK `submissionMode` and must be `sync`, `realtime`, or `sendAndWait`.
- `mode=permit2` uses the server-broadcast charge runtime.
- `mode=hash` uses the client-broadcast verification runtime.
- Split payments are driven per request through `methodDetails.splits`.
- `mode=hash` uses the client-broadcast verification runtime for unsplit charge requests.
- Split payments are driven per request through `methodDetails.splits` and settle sequentially, primary transfer first.
- The demo keeps `mode=permit2` for split routes because `credentialMode: "hash"` is rejected for split charges.
- The browser charge UI checks the connected wallet's Permit2 allowance and can prompt for a one-time infinite approval when the current token allowance is missing or finite.
- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, but generic `mppx` receipt parsing still drops that field.

### Session

Expand Down
10 changes: 9 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ When the settlement wallet is also the payee, opt in visibly with `recipient: se

- `currency`: mainnet USDm
- `permit2Address`: canonical Permit2
- `methodDetails.chainId`: `4326` unless `methodDetails.testnet` is `true`, which forces `6343`

## Flow Selection

Expand Down Expand Up @@ -180,7 +181,7 @@ const sessionMppx = Mppx.create({
| Mode | When to use it | Broadcasts the transaction |
| --- | --- | --- |
| `permit2` | Server should sponsor gas or own the final settlement path | Server |
| `hash` | Payer should broadcast directly and the server only verifies | Client |
| `hash` | Payer should broadcast directly and the server only verifies unsplit payments | Client |

### Submission Mode

Expand All @@ -190,6 +191,13 @@ const sessionMppx = Mppx.create({
| `sync` | Advanced only | Requires `eth_sendRawTransactionSync` support |
| `sendAndWait` | Conservative fallback | Uses the standard send path plus receipt polling |

Charge-specific notes:

- Split charges are encoded as ordered Permit2 `authorizations[]` and settle sequentially, primary transfer first.
- Split charge settlement is non-atomic. A later split can fail after the primary transfer succeeds.
- `credentialMode: "hash"` is rejected for split requests because PR 205 does not yet define a multi-hash split receipt model.
- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, but generic `mppx` receipt parsing still drops that field.

## Charge Process Flows

### Permit2 Credential Mode
Expand Down
39 changes: 31 additions & 8 deletions docs/methods/charge.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This is the best fit when you want:

- one Permit2-backed payment per request
- a simple server-broadcast flow with fee sponsorship
- a client-broadcast fallback that returns a transaction hash instead of a server-submitted settlement
- a client-broadcast fallback that returns a transaction hash instead of a server-submitted settlement for unsplit payments

For the end-to-end walkthrough, start with [../getting-started.md](../getting-started.md).

Expand Down Expand Up @@ -62,8 +62,11 @@ Use explicit overrides when you are:

- Client signs Permit2 typed data.
- Server verifies the challenge, signature, token, amount, splits, and source DID.
- Server broadcasts the Permit2 transaction from the settlement wallet.
- Server broadcasts one Permit2 transaction per transfer leg from the settlement wallet.
- Split authorizations are ordered `authorizations[]`: the primary transfer first, then each split in request order.
- Split settlement is sequential and non-atomic. The primary transfer can succeed even if a later split fails.
- This is the fee-sponsored path.
- Because PR 205 does not yet define a separate Permit2 spender field, the server-broadcast path still requires `recipient` to equal the settlement wallet address.

### Transaction Hash Credential (Client Broadcast)

Expand All @@ -83,6 +86,7 @@ type ChargeRequest = {
externalId?: string
methodDetails: {
chainId?: number
testnet?: boolean
feePayer?: boolean
permit2Address?: `0x${string}`
splits?: Array<{
Expand All @@ -94,16 +98,24 @@ type ChargeRequest = {
}
```

`methodDetails.chainId` is the only public network selector. The old `testnet`
flag is gone. Provide `chainId` either through explicit create-level defaults
or on each request.
Use one of these network selectors:

- `methodDetails.testnet: true`: forces MegaETH testnet `6343`
- otherwise `methodDetails.chainId ?? 4326`

The SDK keeps `testnet` for PR 205 compatibility, but `chainId` remains the
preferred explicit selector when you already know the exact network.

## Client Credential Mode

The client charge factory accepts an optional `credentialMode` parameter:

- `permit2`: return a signed Permit2 credential for server-side verification and broadcast
- `hash`: broadcast the Permit2 transaction from the payer wallet and return a transaction-hash credential
- `hash`: broadcast the Permit2 transaction from the payer wallet and return a transaction-hash credential for unsplit payments

`credentialMode: "hash"` is rejected when `methodDetails.splits` is present
because PR 205 still defines only one hash field while split settlement now uses
multiple non-atomic Permit2 calls.

## Submission Mode

Expand All @@ -119,10 +131,13 @@ defaults to `realtime`. Set it explicitly when you need `sync` or

## Receipt Behavior

`mppx` currently serializes a generic payment receipt header. The SDK keeps the MegaETH request and credential wire format aligned to the draft spec, while the receipt remains compatible with the shared `mppx` receipt serializer.
`mppx` still exposes a generic receipt schema. The SDK keeps the MegaETH
request and credential wire format aligned to the draft spec, and its default
MegaETH HTTP transport adds `challengeId` to the raw `Payment-Receipt` header.

```ts
type ChargeReceipt = {
challengeId: string
method: "megaeth"
reference: string
status: "success"
Expand All @@ -131,7 +146,10 @@ type ChargeReceipt = {
}
```

`challengeId` remains available in server verification context and problem details, but it is not part of the serialized `Payment-Receipt` header.
The SDK's default MegaETH HTTP transport writes `challengeId` into the raw
`Payment-Receipt` header JSON. Generic `mppx` receipt parsing still drops that
field because the upstream receipt schema does not include it, so read the raw
header value directly when your client needs `challengeId`.

## Client Progress Lifecycle

Expand All @@ -154,3 +172,8 @@ All server failures are intentionally instructive. The caller should learn what
- switch back to `credentialMode: "permit2"` when the server sponsors gas

The verification layer maps those failures onto `mppx.Errors.*` problem-details classes so callers can inspect both the human-readable detail and the RFC 9457 `type` URI.

For `charge`, the important result classes are:

- `invalid-challenge` for expired, consumed, or otherwise invalid challenges
- `verification-failed` for signature mismatches, split mismatches, hash-mode misuse, and on-chain settlement failures
5 changes: 4 additions & 1 deletion docs/methods/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ const result = await mppx.megaeth.session({
- the payer or delegated signer signs cumulative EIP-712 vouchers
- the server accepts vouchers, settles periodically, and closes cooperatively

The serialized `Payment-Receipt` header stays `mppx`-compatible in v1. Richer channel state is returned separately by the SDK and demo.
The default MegaETH HTTP transport writes `challengeId` into the raw
`Payment-Receipt` header. Generic `mppx` receipt parsing still drops that field
because the shared upstream receipt schema does not include it. Richer channel
state still returns separately through the SDK and demo.

## Request Shape

Expand Down
6 changes: 6 additions & 0 deletions typescript/packages/mpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ const mppx = Mppx.create({
});
```

## Charge Notes

- Split `charge` requests use ordered Permit2 `authorizations[]` and settle sequentially, primary transfer first.
- `credentialMode: "hash"` is supported only for unsplit charge requests.
- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, while generic `mppx` receipt parsing still drops that field.

## Docs

- Coding agents: [github.com/ifavo/mega-mpp-sdk/blob/main/docs/agent-integration.md](https://github.com/ifavo/mega-mpp-sdk/blob/main/docs/agent-integration.md)
Expand Down
59 changes: 44 additions & 15 deletions typescript/packages/mpp/src/Methods.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Method, z } from "mppx";

import { baseUnitIntegerString } from "./utils/baseUnit.js";
import { MAX_SPLITS } from "./constants.js";

const tokenPermissionSchema = z.object({
token: z.address(),
Expand All @@ -15,7 +16,16 @@ const transferDetailSchema = z.object({
export const splitSchema = z.object({
recipient: z.address(),
amount: baseUnitIntegerString("split amount"),
memo: z.optional(z.string()),
memo: z.optional(
z
.string()
.check(
z.maxLength(
256,
"Use a split memo no longer than 256 characters before retrying the payment.",
),
),
),
});

const permitSingleSchema = z.object({
Expand All @@ -24,18 +34,14 @@ const permitSingleSchema = z.object({
deadline: baseUnitIntegerString("deadline"),
});

const permitBatchSchema = z.object({
permitted: z.array(tokenPermissionSchema),
nonce: baseUnitIntegerString("nonce"),
deadline: baseUnitIntegerString("deadline"),
});

const witnessSingleSchema = z.object({
transferDetails: transferDetailSchema,
});

const witnessBatchSchema = z.object({
transferDetails: z.array(transferDetailSchema),
const permitAuthorizationSchema = z.object({
permit: permitSingleSchema,
witness: witnessSingleSchema,
signature: z.signature(),
});

export const sessionOpenPayloadSchema = z.object({
Expand Down Expand Up @@ -81,9 +87,14 @@ export const charge = Method.from({
payload: z.discriminatedUnion("type", [
z.object({
type: z.literal("permit2"),
permit: z.union([permitSingleSchema, permitBatchSchema]),
witness: z.union([witnessSingleSchema, witnessBatchSchema]),
signature: z.signature(),
authorizations: z
.array(permitAuthorizationSchema)
.check(
z.minLength(
1,
"Use at least one signed Permit2 authorization before retrying the payment.",
),
),
}),
z.object({
type: z.literal("hash"),
Expand All @@ -99,9 +110,25 @@ export const charge = Method.from({
recipient: z.address(),
methodDetails: z.object({
chainId: z.optional(z.number()),
testnet: z.optional(z.boolean()),
feePayer: z.optional(z.boolean()),
permit2Address: z.optional(z.address()),
splits: z.optional(z.array(splitSchema)),
splits: z.optional(
z
.array(splitSchema)
.check(
z.minLength(
1,
"Use at least one split recipient before retrying the payment.",
),
)
.check(
z.maxLength(
MAX_SPLITS,
`Use at most ${MAX_SPLITS} split recipients in one payment request.`,
),
),
),
}),
}),
},
Expand Down Expand Up @@ -150,9 +177,10 @@ export type ChargeRequest = z.output<typeof charge.schema.request>;
export type ChargeSplit = z.output<typeof splitSchema>;
export type TransferDetail = z.output<typeof transferDetailSchema>;
export type PermitSinglePayload = z.output<typeof permitSingleSchema>;
export type PermitBatchPayload = z.output<typeof permitBatchSchema>;
export type TransferSingleWitness = z.output<typeof witnessSingleSchema>;
export type TransferBatchWitness = z.output<typeof witnessBatchSchema>;
export type ChargePermitAuthorization = z.output<
typeof permitAuthorizationSchema
>;
export type ChargePermit2Payload = Extract<
z.output<typeof charge.schema.credential.payload>,
{ type: "permit2" }
Expand All @@ -165,6 +193,7 @@ export type ChargeCredentialPayload = z.output<
typeof charge.schema.credential.payload
>;
export type ChargeReceipt = {
challengeId: string;
method: "megaeth";
reference: string;
status: "success";
Expand Down
18 changes: 16 additions & 2 deletions typescript/packages/mpp/src/__tests__/fixtures/mockContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ contract MockPermit2 {
}

mapping(address => mapping(uint256 => bool)) public usedNonces;
address public failRecipientAfterFirstSuccess;
uint256 public successfulTransfers;

function setFailRecipientAfterFirstSuccess(address recipient) external {
failRecipientAfterFirstSuccess = recipient;
}

function permitWitnessTransferFrom(
PermitTransferFrom calldata permit,
Expand All @@ -131,14 +137,22 @@ contract MockPermit2 {
transferDetails.requestedAmount == permit.permitted.amount,
"Use the exact requested amount before retrying the payment."
);
require(
!(
successfulTransfers > 0 &&
transferDetails.to == failRecipientAfterFirstSuccess
),
"Retry after the split transfer settles successfully."
);
require(
IERC20Like(permit.permitted.token).transferFrom(
owner,
transferDetails.to,
transferDetails.requestedAmount
),
"Retry after the payment token transfer succeeds."
);
"Retry after the payment token transfer succeeds."
);
successfulTransfers += 1;
}

function permitWitnessTransferFrom(
Expand Down
Loading
Loading