@atumlabs/payment-gateway-client 3.0.0 → 3.0.1

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.
@@ -136,7 +136,8 @@ retry:
136
136
  | ---------------------- | --------------------------------- | ------------------------------------------------------ |
137
137
  | `completed` | Settled. | Nothing. `fulfillment_confirmation` carries the proof. |
138
138
  | `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`. |
139
+ | `finalizing` | Paid on chain, collecting confirmations. Not final. | As `pending`. Do not treat it as delivered. |
140
+ | `failed` | Terminal — it can never complete. | A fresh attempt needs a **new** `request_id`. |
140
141
 
141
142
  `pending` is not success. Cross-chain settlement can outrun the gateway's
142
143
  synchronous window, so a payment that has been accepted may still have no result
@@ -0,0 +1,218 @@
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 two names are confusingly close, and that is not an accident of this client: the upstream
19
+ > library calls its document signer `signPaymentRequest` too. This package exports it as
20
+ > `signDeclaration` so the two cannot be confused for one another, matching `SignDeclaration`
21
+ > in the Go client.
22
+
23
+ ## Keys never reach this package
24
+
25
+ Every signing surface takes a **signer**, not a private key:
26
+
27
+ ```ts
28
+ export interface DeclarationSigner {
29
+ getAddress(): Promise<string> | string;
30
+ signDigest(digest: Uint8Array): Promise<Uint8Array> | Uint8Array;
31
+ }
32
+ ```
33
+
34
+ That is deliberate. A hex key forces raw key material into a JavaScript string, which cannot be
35
+ zeroized, is copied on assignment, and lands in heap snapshots and any log line that stringifies
36
+ the surrounding object. It also excludes every custodian: a holder whose key sits in a KMS, an
37
+ HSM, or a provider like Turnkey has no key to hand over.
38
+
39
+ For a local key, use ethers — a library built to hold keys:
40
+
41
+ ```ts
42
+ import { Wallet } from 'ethers';
43
+ import { signerFromEthersWallet } from '@atum-labs/payment-gateway-client/signing';
44
+
45
+ const signer = signerFromEthersWallet(new Wallet(process.env.PRIVATE_KEY!));
46
+ ```
47
+
48
+ For a custodian, implement the interface over its own digest-signing call — `buildEnvelope`
49
+ needs only `signDigest`. Two details the implementation must get right:
50
+
51
+ - **Sign the raw digest.** Not `signMessage` — that applies the EIP-191 prefix, and the
52
+ resulting signature will not verify as a Data Integrity proof.
53
+ - **Return `v ∈ {0, 1}`.** The `+27` alignment is applied once downstream. A signature whose `v`
54
+ is already 27 or 28 is **refused**, because applying it twice recovers a *different* address:
55
+ a well-formed signature that verifies as somebody else.
56
+
57
+ ## Signing both surfaces
58
+
59
+ ```ts
60
+ import { Wallet } from 'ethers';
61
+ import {
62
+ PaymentGatewayClient,
63
+ signPaymentRequest, // transfer authorization — lives at the package root
64
+ createSenderSigner,
65
+ } from '@atum-labs/payment-gateway-client';
66
+ import {
67
+ signDeclaration, // document proof — the signing surface is its own subpath
68
+ signerFromEthersWallet,
69
+ } from '@atum-labs/payment-gateway-client/signing';
70
+
71
+ const wallet = new Wallet(process.env.PRIVATE_KEY!);
72
+
73
+ // 1. Authorize the transfer. Fills every sender_auth.signed_messages[] entry, and attaches
74
+ // payload.delegate_signer on chains whose signatures are not recoverable (Solana).
75
+ const authorized = await signPaymentRequest(request, createSenderSigner(/* ... */));
76
+
77
+ // 2. Attest the document.
78
+ const signed = await signDeclaration(authorized, signerFromEthersWallet(wallet), { chainId: 1 });
79
+
80
+ // 3. Submit.
81
+ await new PaymentGatewayClient({ BASE: gateway }).payments.submitPayment({ requestBody: signed });
82
+ ```
83
+
84
+ ## Check what you are signing
85
+
86
+ `signPaymentRequest` signs **every** entry in `sender_auth.signed_messages[]` as given. It does
87
+ not check that the terms inside those messages are the ones you agreed to, and neither does the
88
+ Go client — the same is true of building the request in the first place, because the permit's
89
+ spender, escrow and amounts come from the gateway's chain defaults.
90
+
91
+ That is a deliberate division of labour, not an oversight: the SDK holds no notion of what you
92
+ intended. But it means a gateway that returned hostile defaults would have your wallet sign a
93
+ permit naming an address you never chose, and nothing in the signing path would object.
94
+
95
+ If you are signing on behalf of someone else's funds, bind the signed permit to the terms **you**
96
+ agreed before the request leaves your process. `verifyPaymentRequest` recovers every
97
+ `signed_messages[]` entry and checks the terms it actually commits to:
98
+
99
+ ```ts
100
+ import { verifyPaymentRequest } from '@atum-labs/payment-gateway-client/signing';
101
+
102
+ // Sign locally, then bind the result to your own terms before submitting.
103
+ const { recoveredAccount } = verifyPaymentRequest(signed, {
104
+ sourceAsset: agreedTokenAddress,
105
+ network: 'eip155:1',
106
+ maxSourceAmount: agreedMaxAmount,
107
+ });
108
+ ```
109
+
110
+ `sourceAsset` is the **bare token address**, not a CAIP-19 identifier — it differs in shape from
111
+ the request's own `source.asset_identifier`, which is full CAIP-19. Passing the CAIP form here
112
+ fails to match and rejects a request that was in fact correct.
113
+
114
+ **Pass those three fields.** Called bare, `verifyPaymentRequest` takes the token, network and
115
+ amount *from the request itself*, so it proves only that the signatures are self-consistent with
116
+ the request's own stated fields — which a hostile gateway controls. It is the `sourceAsset` /
117
+ `network` / `maxSourceAmount` arguments that turn it into a check against your terms, and
118
+ omitting them is the difference between a real check and a reassuring one.
119
+
120
+ It is strict by design: every entry must be a bindable permit, and a malformed one throws rather
121
+ than being skipped. A caller with deliberately mixed messages — say an EVM setup approval
122
+ alongside the permit — must compose the per-entry adapter calls itself and own the skip policy.
123
+
124
+ In Go, `senderauth.GetChainAdapter(caip2).RecoverAndVerify(signed, source, expected)` returns the
125
+ same signed terms for the same comparison.
126
+
127
+ A custodial signer is the natural place to enforce this: it is the component that can refuse.
128
+
129
+ ## Verifying
130
+
131
+ `verifyDeclaration` **throws** on an invalid proof rather than returning a flag — a boolean can
132
+ be ignored by accident, an exception cannot. It also refuses a document whose signature recovers
133
+ a signer other than the one the document claims, which is what makes a tampered document fail
134
+ rather than verify as its original signer.
135
+
136
+ It returns a **discriminated** signer:
137
+
138
+ ```ts
139
+ const { signer } = await verifyDeclaration(signed);
140
+
141
+ if (signer.kind === 'recovered-secp256k1') {
142
+ // The address was recovered FROM the signature: self-evident proof of which key signed.
143
+ console.log(signer.address);
144
+ } else {
145
+ // Self-asserted: the ed25519 key came from the document itself. A successful verify proves
146
+ // the signature matches that key — NOT that the key belongs to anyone in particular. Bind it
147
+ // to a trusted source before relying on it.
148
+ }
149
+ ```
150
+
151
+ ## What the proof does and does not cover
152
+
153
+ The proof is computed over the **URDNA2015-canonicalized** document, so it is stable across key
154
+ reordering and whitespace, and it excludes signing artifacts and the deprecated credential
155
+ carriers.
156
+
157
+ One consequence worth knowing: **canonicalization drops terms the declaration `@context` does not
158
+ define.** A field this client's types do not model travels inside the signed document without
159
+ being covered by the proof, and can be altered without invalidating it. Making a new field
160
+ tamper-evident means adding it to the declaration context, not just to the payload.
161
+
162
+ Any `@context` on the input is **replaced** before hashing, so a caller-supplied one cannot
163
+ influence what gets signed.
164
+
165
+ ## Contexts are local, always
166
+
167
+ JSON-LD contexts are compiled into the bundle. **Nothing is fetched over the network** — there is
168
+ no HTTP client in the signing libraries, and the JSON-LD processor is always handed the local
169
+ loader in place of its built-in fetcher. A document whose `@context` names an unregistered URL is
170
+ refused rather than resolved, since an attacker-served context could otherwise change what each
171
+ term means, and therefore what the signature covers.
172
+
173
+ One precision, because this page is about what your signatures cover: the embedded copies can be
174
+ overridden by an environment variable intended for development against a modified snapshot. It
175
+ reads from local disk, never the network, and nothing in normal operation sets it — but if it is
176
+ set, the library now says so on first use rather than substituting the contexts silently.
177
+
178
+ This is structural: there is no HTTP client in the signing libraries, and the JSON-LD processor
179
+ is always handed the local loader in place of its built-in fetcher.
180
+
181
+ ## The identity envelope
182
+
183
+ **Read this before the example: you need an issuer-signed credential about yourself, and
184
+ originators do not hold one yet.** The schema requires at least one credential and rejects an
185
+ empty bag, so until issuance exists this surface cannot be exercised end to end. It
186
+ is documented because the shape is stable and the signing half is done, not because it is
187
+ callable today.
188
+
189
+ `buildEnvelope` wraps the originator's issuer-signed credentials in a holder-signed
190
+ PaymentRequestEnvelope. It takes the same kind of signer as the other surfaces, so a custodial
191
+ key works here too.
192
+
193
+ ```ts
194
+ import { buildEnvelopeForAccount, computeRequestHash, signerFromEthersWallet } from '@atum-labs/payment-gateway-client/signing';
195
+
196
+ const envelope = await buildEnvelopeForAccount('eip155:1', wallet.address.toLowerCase(), {
197
+ holderVCs: [knownToAtumCredential],
198
+ signer: signerFromEthersWallet(wallet),
199
+ // The challenge is the request's content hash, which the gateway recomputes.
200
+ challenge: await computeRequestHash(request),
201
+ // The domain the gateway you are calling expects. It is compared exactly, so an envelope built
202
+ // for one environment is rejected by another, and it is NOT derived from the gateway URL —
203
+ // take it from that environment's configuration rather than assuming it.
204
+ domain: envelopeDomainForYourEnvironment,
205
+ });
206
+ ```
207
+
208
+ Three things the verifier enforces, so they are not optional:
209
+
210
+ - **At least one credential.** The schema requires it and an empty bag is rejected — see the
211
+ prerequisite above.
212
+ - **The challenge binds one request** and **the domain binds one environment**. Both are checked
213
+ against the values the gateway expects, so an envelope cannot be replayed across either.
214
+ - **The declaration type is bound into the signature**, not merely present in the document —
215
+ editing the `type` array does not make a PaymentRequestEnvelope pass where a FulfillmentQuoteEnvelope is expected.
216
+
217
+ Sign the inner document with the **same key**: the gateway asserts the document's signer is
218
+ the envelope's holder, and rejects the pair otherwise.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atumlabs/payment-gateway-client",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "TypeScript client for Atum Payment Gateway API",
5
5
  "author": "Atum Labs, Inc.",
6
6
  "homepage": "https://github.com/Atum-Labs/protocol/tree/main/payment-gateway-client#readme",
@@ -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",
@@ -50,15 +61,18 @@
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",
62
76
  "tweetnacl": "^1.0.3",
63
77
  "zod": "^3.25.0"
64
78
  },
@@ -66,6 +80,8 @@
66
80
  "@atum-labs/chain-address": "workspace:*",
67
81
  "@atum-labs/evm-escrow-encoding": "workspace:*",
68
82
  "@atum-labs/km-core": "workspace:*",
83
+ "@atum-labs/ld-signing": "workspace:*",
84
+ "@atum-labs/payment-request-ld-signing": "workspace:*",
69
85
  "@atum-labs/payment-request-sender-auth": "workspace:*",
70
86
  "@atum-labs/payment-request-token-approval": "workspace:*",
71
87
  "@atum-labs/publish-guard": "workspace:*",
@@ -90,11 +106,14 @@
90
106
  "CHANGELOG.md",
91
107
  "docs/**",
92
108
  "LICENSE",
93
- "THIRD-PARTY-NOTICES.txt"
109
+ "NOTICE",
110
+ "THIRD-PARTY-NOTICES.txt",
111
+ "signing.js",
112
+ "signing.d.ts"
94
113
  ],
95
- "license": "SEE LICENSE IN LICENSE",
114
+ "license": "Apache-2.0",
96
115
  "publishConfig": {
97
- "access": "restricted"
116
+ "access": "public"
98
117
  },
99
118
  "peerDependencies": {
100
119
  "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');