@atumlabs/payment-gateway-client 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,21 +129,24 @@ altogether is from a gateway predating it.
129
129
 
130
130
  ## Reading the outcome (payments)
131
131
 
132
- `status` is on every payment response, and the three endings mean different things for a
133
- retry:
132
+ `status` is on every payment response, and its endings mean different things for a retry:
134
133
 
135
134
  | `status` | What it means | What to do |
136
135
  | ---------------------- | --------------------------------- | ------------------------------------------------------ |
137
136
  | `completed` | Settled. | Nothing. `fulfillment_confirmation` carries the proof. |
138
137
  | `pending` | Accepted, still settling. | Re-submit the **same** `request_id`, or read the status endpoint. Do not start a new payment. |
139
- | `failed` / `cancelled` | Terminal — it can never complete. | A fresh attempt needs a **new** `request_id`. |
138
+ | `finalizing` | Paid on chain, collecting confirmations. Not final. | As `pending`. Do not treat it as delivered. |
139
+ | `failed` | Terminal — it can never complete. | A fresh attempt needs a **new** `request_id`. |
140
+ | `refunding` | Not delivered, the escrowed funds are being returned. Not final. | As `pending`. |
141
+ | `refund_available` | Not delivered, funds still in escrow. The status endpoint returns `refund_signature`. Not final. | Submit `refund_signature` to the source escrow to return the funds. Do not start a new payment until it is `refunded`: a late delivery can still complete it. |
142
+ | `refunded` | Terminal, not delivered. The funds were returned. | A fresh attempt needs a **new** `request_id`. |
140
143
 
141
144
  `pending` is not success. Cross-chain settlement can outrun the gateway's
142
145
  synchronous window, so a payment that has been accepted may still have no result
143
146
  when you get your response.
144
147
 
145
- A terminal failure **keeps** its `request_id`. Re-submitting that id returns the
146
- same failed payment for good, which is why recovering from one means minting a
148
+ A terminal payment **keeps** its `request_id`. Re-submitting that id returns the
149
+ same payment for good, which is why recovering from one means minting a
147
150
  new id — the opposite of what a pending payment needs. Getting these two the
148
151
  wrong way round is how a caller either pays twice or waits for something that
149
152
  will never happen.
@@ -0,0 +1,221 @@
1
+ # Signing a PaymentRequest
2
+
3
+ A PaymentRequest carries **three unrelated signatures**. They are produced by different calls,
4
+ cover different things, and none substitutes for another. Choosing the wrong one is the mistake
5
+ this page exists to prevent.
6
+
7
+ | What you are signing | Call | What it authorizes |
8
+ |---|---|---|
9
+ | `sender_auth.signed_messages[]` | `signPaymentRequest` | the on-chain **transfer** |
10
+ | the document's `proof` | `signDeclaration` | that the **document** came from its signer |
11
+ | `presentation` | `buildEnvelope` | the **originator's identity** |
12
+
13
+ A request with a valid `sender_auth` and no identity envelope authorizes a transfer by an
14
+ unidentified originator. The reverse identifies an originator who has authorized nothing. Most
15
+ integrations need the first two; the third applies once the originator holds credentials about
16
+ itself.
17
+
18
+ > The underlying declaration library also names its document signer `signPaymentRequest`. This
19
+ > package exports it as `signDeclaration`, matching `SignDeclaration` in the Go client, so the two
20
+ > cannot be confused.
21
+
22
+ ## Keys never reach this package
23
+
24
+ Every signing surface takes a **signer**, not a private key:
25
+
26
+ ```ts
27
+ export interface DeclarationSigner {
28
+ getAddress(): Promise<string> | string;
29
+ signDigest(digest: Uint8Array): Promise<Uint8Array> | Uint8Array;
30
+ }
31
+ ```
32
+
33
+ That is deliberate. A hex key forces raw key material into a JavaScript string, which cannot be
34
+ zeroized, is copied on assignment, and lands in heap snapshots and any log line that stringifies
35
+ the surrounding object. It also excludes every custodian: a holder whose key sits in a KMS, an
36
+ HSM, or a provider like Turnkey has no key to hand over.
37
+
38
+ For a local key, use ethers — a library built to hold keys:
39
+
40
+ ```ts
41
+ import { Wallet } from 'ethers';
42
+ import { signerFromEthersWallet } from '@atum-labs/payment-gateway-client/signing';
43
+
44
+ const signer = signerFromEthersWallet(new Wallet(process.env.PRIVATE_KEY!));
45
+ ```
46
+
47
+ For a custodian, implement the interface over its own digest-signing call — `buildEnvelope`
48
+ needs only `signDigest`. Two details the implementation must get right:
49
+
50
+ - **Sign the raw digest.** Not `signMessage` — that applies the EIP-191 prefix, and the
51
+ resulting signature will not verify as a Data Integrity proof.
52
+ - **Return `v ∈ {0, 1}`.** The `+27` alignment is applied once downstream. A signature whose `v`
53
+ is already 27 or 28 is **refused**, because applying it twice recovers a *different* address:
54
+ a well-formed signature that verifies as somebody else.
55
+
56
+ ## Signing both surfaces
57
+
58
+ ```ts
59
+ import { Wallet } from 'ethers';
60
+ import {
61
+ PaymentGatewayClient,
62
+ signPaymentRequest, // transfer authorization — lives at the package root
63
+ createSenderSigner,
64
+ } from '@atum-labs/payment-gateway-client';
65
+ import {
66
+ signDeclaration, // document proof — the signing surface is its own subpath
67
+ signerFromEthersWallet,
68
+ } from '@atum-labs/payment-gateway-client/signing';
69
+
70
+ const wallet = new Wallet(process.env.PRIVATE_KEY!);
71
+
72
+ // 1. Authorize the transfer. Fills every sender_auth.signed_messages[] entry, and attaches
73
+ // payload.delegate_signer on chains whose signatures are not recoverable (Solana).
74
+ const authorized = await signPaymentRequest(request, createSenderSigner(/* ... */));
75
+
76
+ // 2. Attest the document.
77
+ const signed = await signDeclaration(authorized, signerFromEthersWallet(wallet), { chainId: 1 });
78
+
79
+ // 3. Submit.
80
+ await new PaymentGatewayClient({ BASE: gateway }).payments.submitPayment({ requestBody: signed });
81
+ ```
82
+
83
+ ## Check what you are signing
84
+
85
+ `signPaymentRequest` signs **every** entry in `sender_auth.signed_messages[]` as given, in both
86
+ the TypeScript and Go clients. The permit's spender, escrow and amounts come from the gateway's
87
+ chain defaults, and the signer has no notion of what you intended.
88
+
89
+ Bind the signed permit to the terms **you** agreed before the request leaves your process.
90
+ `verifyPaymentRequest` recovers every `signed_messages[]` entry and checks the terms it commits
91
+ to:
92
+
93
+ ```ts
94
+ import { verifyPaymentRequest } from '@atum-labs/payment-gateway-client/signing';
95
+
96
+ // Sign locally, then bind the result to your own terms before submitting.
97
+ const { recoveredAccount } = verifyPaymentRequest(signed, {
98
+ sourceAsset: agreedTokenAddress,
99
+ network: 'eip155:1',
100
+ maxSourceAmount: agreedMaxAmount,
101
+ });
102
+ ```
103
+
104
+ `sourceAsset` is the **bare token address**, not the CAIP-19 identifier used in the request's
105
+ `source.asset_identifier`. Passing the CAIP form fails to match.
106
+
107
+ **Pass those three fields.** Without them, `verifyPaymentRequest` takes the token, network and
108
+ amount from the request itself, which proves only that the signatures are consistent with the
109
+ request's own fields.
110
+
111
+ It always checks the payee: the deposit must commit to the request's own `destination` and
112
+ `request_id`, so a request whose payee or request id was changed after signing is refused.
113
+
114
+ If you hold the request as JSON text, call `verifyPaymentRequestJSON(json, expected)` instead. It
115
+ also refuses a repeated member name, including names that differ only in letter case, where it is
116
+ read: any top-level name, or inside `source`, `destination`, `request_id` or `sender_auth`.
117
+ On a refusal, `commitmentResultOf(error)` returns the outcome (for example
118
+ `destination_mismatch`) and both copies of the payee, ready to log. A verified entry carries the
119
+ same result as `results[i].commitment`.
120
+
121
+ **Contract-account payers.** `verifyPaymentRequest` recovers each signer, so it refuses a payer
122
+ that is a contract account (ERC-1271, such as a Safe): its signature comes from an owner key, never
123
+ from the account itself. For such a payer, call `verifyRequestCommitment(json)`. It checks the
124
+ payee and request id the deposit commits to without recovering the signer; Permit2 checks the
125
+ signature on chain when the deposit is made.
126
+
127
+ Every entry must be a bindable permit; a malformed entry throws. A caller with mixed messages
128
+ (for example an EVM setup approval alongside the permit) composes the per-entry adapter calls
129
+ itself.
130
+
131
+ In Go, `senderauth.VerifyRequestCommitment(raw, solanaDomain)` is the payee check alone,
132
+ `senderauth.VerifyPaymentRequestJSON(raw, expected)` and
133
+ `senderauth.VerifyPaymentRequest(request, expected)` run the same checks,
134
+ `senderauth.CommitmentResultOf(err)` reads the outcome from an error, and
135
+ `senderauth.GetChainAdapter(caip2).RecoverAndVerify(signed, source, commitment, expected)` checks
136
+ one signed message against a `CommitmentExpectation` built from the request's `destination` and
137
+ `request_id`.
138
+
139
+ A custodial signer is the natural place to enforce this: it is the component that can refuse.
140
+
141
+ ## Verifying
142
+
143
+ `verifyDeclaration` **throws** on an invalid proof rather than returning a flag — a boolean can
144
+ be ignored by accident, an exception cannot. It also refuses a document whose signature recovers
145
+ a signer other than the one the document claims, which is what makes a tampered document fail
146
+ rather than verify as its original signer.
147
+
148
+ It returns a **discriminated** signer:
149
+
150
+ ```ts
151
+ const { signer } = await verifyDeclaration(signed);
152
+
153
+ if (signer.kind === 'recovered-secp256k1') {
154
+ // The address was recovered FROM the signature: self-evident proof of which key signed.
155
+ console.log(signer.address);
156
+ } else {
157
+ // Self-asserted: the ed25519 key came from the document itself. A successful verify proves
158
+ // the signature matches that key — NOT that the key belongs to anyone in particular. Bind it
159
+ // to a trusted source before relying on it.
160
+ }
161
+ ```
162
+
163
+ ## What the proof does and does not cover
164
+
165
+ The proof is computed over the **URDNA2015-canonicalized** document, so it is stable across key
166
+ reordering and whitespace, and it excludes signing artifacts and the deprecated credential
167
+ carriers.
168
+
169
+ One consequence worth knowing: **canonicalization drops terms the declaration `@context` does not
170
+ define.** A field this client's types do not model travels inside the signed document without
171
+ being covered by the proof, and can be altered without invalidating it. Making a new field
172
+ tamper-evident means adding it to the declaration context, not just to the payload.
173
+
174
+ Any `@context` on the input is **replaced** before hashing, so a caller-supplied one cannot
175
+ influence what gets signed.
176
+
177
+ ## Contexts are local, always
178
+
179
+ JSON-LD contexts are compiled into the bundle. **Nothing is fetched over the network** — there is
180
+ no HTTP client in the signing libraries, and the JSON-LD processor is always handed the local
181
+ loader in place of its built-in fetcher. A document whose `@context` names an unregistered URL is
182
+ refused rather than resolved, since an attacker-served context could otherwise change what each
183
+ term means, and therefore what the signature covers.
184
+
185
+ For development against a modified snapshot, an environment variable can override the embedded
186
+ copies. It reads from local disk only, and the library reports it on first use when it is set.
187
+
188
+ ## The identity envelope
189
+
190
+ **Prerequisite: at least one issuer-signed credential about the originator.** The schema rejects
191
+ an envelope without one.
192
+
193
+ `buildEnvelope` wraps the originator's issuer-signed credentials in a holder-signed
194
+ PaymentRequestEnvelope. It takes the same kind of signer as the other surfaces, so a custodial
195
+ key works here too.
196
+
197
+ ```ts
198
+ import { buildEnvelopeForAccount, computeRequestHash, signerFromEthersWallet } from '@atum-labs/payment-gateway-client/signing';
199
+
200
+ const envelope = await buildEnvelopeForAccount('eip155:1', wallet.address.toLowerCase(), {
201
+ holderVCs: [knownToAtumCredential],
202
+ signer: signerFromEthersWallet(wallet),
203
+ // The challenge is the request's content hash, which the gateway recomputes.
204
+ challenge: await computeRequestHash(request),
205
+ // The domain the gateway you are calling expects. It is compared exactly, so an envelope built
206
+ // for one environment is rejected by another, and it is NOT derived from the gateway URL —
207
+ // take it from that environment's configuration rather than assuming it.
208
+ domain: envelopeDomainForYourEnvironment,
209
+ });
210
+ ```
211
+
212
+ Three things the verifier enforces, so they are not optional:
213
+
214
+ - **At least one credential.** An empty bag is rejected.
215
+ - **The challenge binds one request** and **the domain binds one environment**. Both are checked
216
+ against the values the gateway expects, so an envelope cannot be replayed across either.
217
+ - **The declaration type is bound into the signature**, not merely present in the document —
218
+ editing the `type` array does not make a PaymentRequestEnvelope pass where a FulfillmentQuoteEnvelope is expected.
219
+
220
+ Sign the inner document with the **same key**: the gateway asserts the document's signer is
221
+ the envelope's holder, and rejects the pair otherwise.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@atumlabs/payment-gateway-client",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "TypeScript client for Atum Payment Gateway API",
5
5
  "author": "Atum Labs, Inc.",
6
- "homepage": "https://github.com/Atum-Labs/protocol/tree/main/payment-gateway-client#readme",
6
+ "homepage": "https://github.com/Atum-Labs/protocol/tree/main/schemas/apis/payment-gateway/v1/bindings/payment-gateway-client#readme",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/Atum-Labs/protocol.git",
10
- "directory": "payment-gateway-client"
10
+ "directory": "schemas/apis/payment-gateway/v1/bindings/payment-gateway-client"
11
11
  },
12
12
  "bugs": {
13
13
  "url": "https://github.com/Atum-Labs/protocol/issues"
@@ -31,6 +31,17 @@
31
31
  },
32
32
  "main": "dist/index.js",
33
33
  "types": "dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./signing": {
40
+ "types": "./dist/signing.d.ts",
41
+ "default": "./dist/signing.js"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
34
45
  "bin": {
35
46
  "send-payment": "./dist/cli/send-payment.js",
36
47
  "request-quote": "./dist/cli/request-quote.js",
@@ -39,9 +50,9 @@
39
50
  "approve-permit2": "./dist/cli/approve-permit2.js"
40
51
  },
41
52
  "scripts": {
42
- "generate": "openapi-ts --input ../schemas/apis/payment-gateway/v1/openapi.yaml --output \"${PGC_GEN_OUT:-./src/generated}\" --client axios --name PaymentGatewayClient --useOptions",
53
+ "generate": "openapi-ts --input ../../openapi.yaml --output \"${PGC_GEN_OUT:-./src/generated}\" --client axios --name PaymentGatewayClient --useOptions",
43
54
  "lint": "eslint . --max-warnings 0",
44
- "prebuild:deps": "node -e \"if(!require('fs').existsSync('../pnpm-workspace.yaml')){console.error('protocol workspace not found at ../pnpm-workspace.yaml. Is this checked out inside the protocol monorepo?');process.exit(1)}\"",
55
+ "prebuild:deps": "node -e \"if(!require('fs').existsSync('../../../../../../pnpm-workspace.yaml')){console.error('protocol workspace not found at ../../../../../../pnpm-workspace.yaml. Is this checked out inside the protocol monorepo?');process.exit(1)}\"",
45
56
  "build:deps": "pnpm --filter \"@atum-labs/payment-gateway-client^...\" run build",
46
57
  "build": "tsup && chmod +x dist/cli/*.js && node scripts/stamp-entry-headers.mjs",
47
58
  "typecheck:tests": "tsc -p tsconfig.test.json",
@@ -50,15 +61,19 @@
50
61
  "check:publishable": "node scripts/check-publishable.mjs",
51
62
  "gen:notices": "node scripts/gen-third-party-notices.mjs",
52
63
  "prepare": "pnpm run build",
53
- "prepublishOnly": "pnpm run build && pnpm run check:publishable"
64
+ "prepublishOnly": "pnpm run build && pnpm run check:publishable",
65
+ "prebuild": "node scripts/gen-pre-context.mjs",
66
+ "check:pre-context": "node scripts/gen-pre-context.mjs --check"
54
67
  },
55
68
  "dependencies": {
56
69
  "@solana/web3.js": "^1.91.0",
57
- "axios": "1.13.2",
70
+ "axios": "1.20.0",
58
71
  "bs58": "^6.0.0",
59
72
  "commander": "^12.0.0",
60
73
  "dotenv": "^16.6.1",
61
74
  "ethers": "^6.11.0",
75
+ "jsonld": "8.3.3",
76
+ "rdf-canonize": "3.4.0",
62
77
  "tweetnacl": "^1.0.3",
63
78
  "zod": "^3.25.0"
64
79
  },
@@ -66,9 +81,12 @@
66
81
  "@atum-labs/chain-address": "workspace:*",
67
82
  "@atum-labs/evm-escrow-encoding": "workspace:*",
68
83
  "@atum-labs/km-core": "workspace:*",
84
+ "@atum-labs/ld-signing": "workspace:*",
85
+ "@atum-labs/payment-request-ld-signing": "workspace:*",
69
86
  "@atum-labs/payment-request-sender-auth": "workspace:*",
70
87
  "@atum-labs/payment-request-token-approval": "workspace:*",
71
88
  "@atum-labs/publish-guard": "workspace:*",
89
+ "@atum-labs/schema-declarations": "workspace:*",
72
90
  "@atum-labs/solana-escrow-encoding": "workspace:*",
73
91
  "@atum-labs/turnkey-signer": "workspace:*",
74
92
  "@atum-labs/tvm-escrow-encoding": "workspace:*",
@@ -82,7 +100,7 @@
82
100
  "ts-jest": "^29.1.1",
83
101
  "tsup": "^8.5.1",
84
102
  "typescript": "^5.0.0",
85
- "uuid": "^9.0.0"
103
+ "uuid": "^11.1.1"
86
104
  },
87
105
  "files": [
88
106
  "dist/**/*",
@@ -90,11 +108,14 @@
90
108
  "CHANGELOG.md",
91
109
  "docs/**",
92
110
  "LICENSE",
93
- "THIRD-PARTY-NOTICES.txt"
111
+ "NOTICE",
112
+ "THIRD-PARTY-NOTICES.txt",
113
+ "signing.js",
114
+ "signing.d.ts"
94
115
  ],
95
- "license": "SEE LICENSE IN LICENSE",
116
+ "license": "Apache-2.0",
96
117
  "publishConfig": {
97
- "access": "restricted"
118
+ "access": "public"
98
119
  },
99
120
  "peerDependencies": {
100
121
  "tronweb": "^6.0.0"
package/signing.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Types for the subpath shim. See signing.js for why both exist.
2
+ export * from './dist/signing';
package/signing.js ADDED
@@ -0,0 +1,11 @@
1
+ // Subpath shim for the classic Node resolution algorithm.
2
+ //
3
+ // package.json's "exports" map already declares "./signing", but `moduleResolution: "node"` —
4
+ // still the default for plenty of consumers — does not read "exports" at all. It resolves
5
+ // `@atum-labs/payment-gateway-client/signing` by looking for this file on disk. Without it the
6
+ // subpath works under "node16"/"bundler" and fails everywhere else, which is a failure a
7
+ // consumer meets at install rather than one we meet in CI.
8
+ //
9
+ // The publish guard caught exactly that: it type-checks against the packed tarball on the default
10
+ // settings, and the import did not resolve until this existed.
11
+ module.exports = require('./dist/signing.js');