Skip to content

Commit 1e2c020

Browse files
authored
feat: either receiveAmount or debitAmount allowed for outgoing payment limits (#3449)
* Set limits to have either receive or debit amount * Updated auth limits and bruno request * Added the case when both types of amount are missing * Updated mock ASE consent screen, renamed error, added check in auth for both types of amount * Added error enum for access, added tests and updated types * Added error mapping for grants, updated tests * Format fixes and ignored eslint error * Updated createGrant function for testing * Forgot to rollback transaction when error will be thrown * Simplified access error handling * Removed unnecessary assert and changed error code * Fixed mock consent screen so it will support having either receive or debit amount * Updated consent messages * Updated mock ASE consent screen to handle all limit amount cases
1 parent 2fcadae commit 1e2c020

15 files changed

Lines changed: 310 additions & 66 deletions

File tree

bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ body:json {
2121
],
2222
"identifier": "{{senderWalletAddress}}",
2323
"limits": {
24-
"debitAmount": {{quoteDebitAmount}},
25-
"receiveAmount": {{quoteReceiveAmount}}
24+
"debitAmount": {{quoteDebitAmount}}
2625
}
2726
}
2827
]

bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ body:json {
2121
],
2222
"identifier": "{{senderWalletAddress}}",
2323
"limits": {
24-
"debitAmount": {{quoteDebitAmount}},
25-
"receiveAmount": {{quoteReceiveAmount}}
24+
"debitAmount": {{quoteDebitAmount}}
2625
}
2726
}
2827
]

bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,6 @@ body:json {
3131
"value": "8000",
3232
"assetCode": "USD",
3333
"assetScale": 2
34-
},
35-
"receiveAmount": {
36-
"value": "8000",
37-
"assetCode": "USD",
38-
"assetScale": 2
3934
}
4035
}
4136
}

localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx

Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ interface GrantAmount {
3030
currencyDisplayCode: string
3131
}
3232

33+
export enum AmountType {
34+
DEBIT = 'debit',
35+
RECEIVE = 'receive',
36+
UNLIMITED = 'unlimited'
37+
}
38+
3339
export function loader() {
3440
return json({ defaultIdpSecret: CONFIG.idpSecret })
3541
}
@@ -45,8 +51,8 @@ function ConsentScreenBody({
4551
}: {
4652
_thirdPartyUri: string
4753
thirdPartyName: string
48-
price: GrantAmount
49-
costToUser: GrantAmount
54+
price: GrantAmount | null
55+
costToUser: GrantAmount | null
5056
interactId: string
5157
nonce: string
5258
returnUrl: string
@@ -80,6 +86,15 @@ function ConsentScreenBody({
8086
)}
8187
</div>
8288
</div>
89+
<div className='row mt-2'>
90+
<div className='col-12'>
91+
{!price && !costToUser && (
92+
<p>
93+
{thirdPartyName} is requesting grant for an unlimited amount
94+
</p>
95+
)}
96+
</div>
97+
</div>
8398
<div className='row mt-2'>
8499
<div className='col-12'>Do you consent?</div>
85100
</div>
@@ -297,21 +312,29 @@ export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) {
297312
)
298313
returnUrlObject.searchParams.append(
299314
'currencyDisplayCode',
300-
outgoingPaymentAccess && outgoingPaymentAccess.limits
301-
? outgoingPaymentAccess.limits.debitAmount.assetCode
302-
: null
315+
outgoingPaymentAccess?.limits?.debitAmount?.assetCode ??
316+
outgoingPaymentAccess?.limits?.receiveAmount?.assetCode ??
317+
null
303318
)
304319
returnUrlObject.searchParams.append(
305-
'sendAmountValue',
306-
outgoingPaymentAccess && outgoingPaymentAccess.limits
307-
? outgoingPaymentAccess.limits.debitAmount.value
308-
: null
320+
'amountValue',
321+
outgoingPaymentAccess?.limits?.debitAmount?.value ??
322+
outgoingPaymentAccess?.limits?.receiveAmount?.value ??
323+
null
309324
)
310325
returnUrlObject.searchParams.append(
311-
'sendAmountScale',
312-
outgoingPaymentAccess && outgoingPaymentAccess.limits
313-
? outgoingPaymentAccess.limits.debitAmount.assetScale
314-
: null
326+
'amountScale',
327+
outgoingPaymentAccess?.limits?.debitAmount?.assetScale ??
328+
outgoingPaymentAccess?.limits?.receiveAmount?.assetScale ??
329+
null
330+
)
331+
returnUrlObject.searchParams.append(
332+
'amountType',
333+
outgoingPaymentAccess?.limits?.receiveAmount
334+
? AmountType.RECEIVE
335+
: outgoingPaymentAccess?.limits?.debitAmount
336+
? AmountType.DEBIT
337+
: AmountType.UNLIMITED
315338
)
316339
setCtx({
317340
...ctx,
@@ -337,33 +360,43 @@ export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) {
337360
ctx.errors.length === 0 &&
338361
ctx.ready &&
339362
ctx.outgoingPaymentAccess &&
340-
(!ctx.price || !ctx.costToUser)
363+
!ctx.price &&
364+
!ctx.costToUser
341365
) {
342-
if (
343-
ctx.outgoingPaymentAccess.limits &&
344-
ctx.outgoingPaymentAccess.limits.debitAmount &&
345-
ctx.outgoingPaymentAccess.limits.receiveAmount
346-
) {
347-
const { receiveAmount, debitAmount } = ctx.outgoingPaymentAccess.limits
348-
setCtx({
349-
...ctx,
350-
price: {
351-
amount:
352-
Number(receiveAmount.value) /
353-
Math.pow(10, receiveAmount.assetScale),
354-
currencyDisplayCode: receiveAmount.assetCode
355-
},
356-
costToUser: {
357-
amount:
358-
Number(debitAmount.value) / Math.pow(10, debitAmount.assetScale),
359-
currencyDisplayCode: debitAmount.assetCode
360-
}
361-
})
362-
} else {
363-
setCtx({
364-
...ctx,
365-
errors: [new Error('missing or incomplete outgoing payment access')]
366-
})
366+
if (ctx.outgoingPaymentAccess.limits) {
367+
if (
368+
ctx.outgoingPaymentAccess.limits.debitAmount &&
369+
ctx.outgoingPaymentAccess.limits.receiveAmount
370+
) {
371+
setCtx({
372+
...ctx,
373+
errors: [
374+
new Error('only one of receiveAmount or debitAmount allowed')
375+
]
376+
})
377+
} else {
378+
const { receiveAmount, debitAmount } =
379+
ctx.outgoingPaymentAccess.limits
380+
setCtx({
381+
...ctx,
382+
...(receiveAmount && {
383+
price: {
384+
amount:
385+
Number(receiveAmount.value) /
386+
Math.pow(10, receiveAmount.assetScale),
387+
currencyDisplayCode: receiveAmount.assetCode
388+
}
389+
}),
390+
...(debitAmount && {
391+
costToUser: {
392+
amount:
393+
Number(debitAmount.value) /
394+
Math.pow(10, debitAmount.assetScale),
395+
currencyDisplayCode: debitAmount.assetCode
396+
}
397+
})
398+
})
399+
}
367400
}
368401
}
369402
}, [ctx, setCtx])
@@ -379,7 +412,7 @@ export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) {
379412
</div>
380413
{ctx.ready ? (
381414
<>
382-
{ctx.errors.length > 0 || !ctx.price || !ctx.costToUser ? (
415+
{ctx.errors.length > 0 ? (
383416
<>
384417
<h2 className='display-6'>Failed</h2>
385418
<ul>

localenv/mock-account-servicing-entity/app/routes/mock-idp.consent.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CONFIG as config } from '~/lib/parse_config.server'
1010
import { Button } from '~/components'
1111
import { CheckCircleSolid, XCircle } from '~/components/icons'
1212
import { InstanceConfig } from '~/lib/types'
13+
import { AmountType } from './mock-idp._index'
1314

1415
export function loader() {
1516
return json({
@@ -24,26 +25,39 @@ function AuthorizedView({
2425
amount,
2526
interactId,
2627
nonce,
27-
authServerDomain
28+
authServerDomain,
29+
amountType
2830
}: {
2931
thirdPartyName: string
3032
currencyDisplayCode: string
3133
amount: number
3234
interactId: string
3335
nonce: string
3436
authServerDomain: string
37+
amountType: string
3538
}) {
39+
let message = `You gave ${thirdPartyName} permission to `
40+
switch (amountType) {
41+
case AmountType.RECEIVE:
42+
message += `receive ${currencyDisplayCode} ${amount.toFixed(2)} in your account.`
43+
break
44+
case AmountType.DEBIT:
45+
message += `send ${currencyDisplayCode} ${amount.toFixed(2)} out of your account.`
46+
break
47+
case AmountType.UNLIMITED:
48+
message += 'have unlimited access to your account.'
49+
break
50+
default:
51+
message = 'Type of authorization is missing'
52+
}
3653
return (
3754
<div className='bg-white rounded-md p-8 px-16'>
3855
<div className='row mt-2 flex flex-row items-center justify-around'>
3956
<div>
4057
<CheckCircleSolid className='w-16 h-16 text-green-400 flex-shrink-0 mr-6' />
4158
</div>
4259
<div>
43-
<p>
44-
You gave {thirdPartyName} permission to send {currencyDisplayCode}{' '}
45-
{amount.toFixed(2)} out of your account.
46-
</p>
60+
<p>{message}</p>
4761
</div>
4862
</div>
4963
<div className='row mt-2'>
@@ -114,8 +128,9 @@ export default function Consent() {
114128
thirdPartyUri: queryParams.get('thirdPartyUri'),
115129
currencyDisplayCode: queryParams.get('currencyDisplayCode'),
116130
amount:
117-
Number(queryParams.get('sendAmountValue')) /
118-
Math.pow(10, Number(queryParams.get('sendAmountScale')))
131+
Number(queryParams.get('amountValue')) /
132+
Math.pow(10, Number(queryParams.get('amountScale'))),
133+
amountType: queryParams.get('amountType')
119134
})
120135

121136
useEffect(() => {
@@ -168,6 +183,7 @@ export default function Consent() {
168183
interactId={ctx.interactId}
169184
nonce={ctx.nonce}
170185
authServerDomain={authServerDomain}
186+
amountType={ctx.amountType || ''}
171187
/>
172188
) : (
173189
<RejectedView

packages/auth/src/access/errors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { GNAPErrorCode } from '../shared/gnapErrors'
2+
3+
export enum AccessError {
4+
OnlyOneAccessAmountAllowed = 'only one access amount allowed'
5+
}
6+
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
8+
export const isAccessError = (o: any): o is AccessError =>
9+
Object.values(AccessError).includes(o)
10+
11+
export const errorToHTTPCode: {
12+
[key in AccessError]: number
13+
} = {
14+
[AccessError.OnlyOneAccessAmountAllowed]: 400
15+
}
16+
17+
export const errorToGNAPCode: {
18+
[key in AccessError]: GNAPErrorCode
19+
} = {
20+
[AccessError.OnlyOneAccessAmountAllowed]: GNAPErrorCode.InvalidRequest
21+
}

packages/auth/src/access/service.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AppServices } from '../app'
1111
import { AccessService } from './service'
1212
import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model'
1313
import { IncomingPaymentRequest, OutgoingPaymentRequest } from './types'
14+
import { AccessError } from './errors'
1415
import { generateNonce, generateToken } from '../shared/utils'
1516
import { AccessType, AccessAction } from '@interledger/open-payments'
1617
import { Access } from './model'
@@ -75,11 +76,6 @@ describe('Access Service', (): void => {
7576
assetCode: 'usd',
7677
assetScale: 9
7778
},
78-
receiveAmount: {
79-
value: '2000000000',
80-
assetCode: 'usd',
81-
assetScale: 9
82-
},
8379
expiresAt: new Date().toISOString(),
8480
receiver: 'https://wallet.com/alice'
8581
}
@@ -99,6 +95,38 @@ describe('Access Service', (): void => {
9995
expect(access[0].type).toEqual(AccessType.OutgoingPayment)
10096
expect(access[0].limits).toEqual(outgoingPaymentLimit)
10197
})
98+
99+
test('Does not create outgoing payment access when both receiveAmount and debitAmount are provided to limits', async (): Promise<void> => {
100+
const outgoingPaymentLimit = {
101+
debitAmount: {
102+
value: '1000000000',
103+
assetCode: 'usd',
104+
assetScale: 9
105+
},
106+
receiveAmount: {
107+
value: '1000000000',
108+
assetCode: 'usd',
109+
assetScale: 9
110+
},
111+
expiresAt: new Date().toISOString(),
112+
receiver: 'https://wallet.com/alice'
113+
}
114+
115+
const outgoingPaymentAccess: OutgoingPaymentRequest = {
116+
type: 'outgoing-payment',
117+
actions: [AccessAction.Create, AccessAction.Read, AccessAction.List],
118+
limits: outgoingPaymentLimit
119+
}
120+
121+
try {
122+
await accessService.createAccess(grant.id, [outgoingPaymentAccess])
123+
fail(
124+
'Expected createAccess to throw OnlyOneAccessAmountAllowed, but no error was thrown'
125+
)
126+
} catch (err) {
127+
expect(err).toBe(AccessError.OnlyOneAccessAmountAllowed)
128+
}
129+
})
102130
})
103131

104132
describe('getByGrant', (): void => {

packages/auth/src/access/service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Transaction, TransactionOrKnex } from 'objection'
33
import { BaseService } from '../shared/baseService'
44
import { Access } from './model'
55
import { AccessRequest } from './types'
6+
import { AccessError } from './errors'
67

78
export interface AccessService {
89
createAccess(
@@ -47,6 +48,8 @@ async function createAccess(
4748
accessRequests: AccessRequest[],
4849
trx?: Transaction
4950
): Promise<Access[]> {
51+
validateLimits(accessRequests)
52+
5053
const accessRequestsWithGrant = accessRequests.map((access) => {
5154
return { grantId, ...access }
5255
})
@@ -63,3 +66,13 @@ async function getByGrant(
6366
grantId
6467
})
6568
}
69+
70+
function validateLimits(accessRequests: AccessRequest[]) {
71+
const areBothLimitsSet = accessRequests.some(
72+
(access) => access.limits?.debitAmount && access.limits.receiveAmount
73+
)
74+
75+
if (areBothLimitsSet) {
76+
throw AccessError.OnlyOneAccessAmountAllowed
77+
}
78+
}

0 commit comments

Comments
 (0)