@@ -16,7 +16,7 @@ import {
1616} from "./configs/acpConfigs" ;
1717import { USDC_TOKEN_ADDRESS } from "./constants" ;
1818import { AcpAccount } from "./acpAccount" ;
19- import { ISubscriptionCheckResponse } from "./interfaces" ;
19+ import { IAcpAccount , ISubscriptionCheckResponse } from "./interfaces" ;
2020
2121export enum PriceType {
2222 FIXED = "fixed" ,
@@ -45,6 +45,48 @@ class AcpJobOffering {
4545 expiredAt : Date = new Date ( Date . now ( ) + 1000 * 60 * 60 * 24 ) , // default: 1 day
4646 preferredSubscriptionTier ?: string ,
4747 ) {
48+ this . validateRequest ( serviceRequirement ) ;
49+
50+ const subscriptionRequired = this . isSubscriptionRequired ( preferredSubscriptionTier ) ;
51+ this . validateSubscriptionTier ( preferredSubscriptionTier ) ;
52+
53+ const effectivePrice = subscriptionRequired ? 0 : this . price ;
54+ const effectivePriceType = subscriptionRequired
55+ ? PriceType . SUBSCRIPTION
56+ : this . priceType === PriceType . SUBSCRIPTION
57+ ? PriceType . FIXED
58+ : this . priceType ;
59+
60+ const fareAmount = new FareAmount (
61+ effectivePriceType === PriceType . FIXED ? effectivePrice : 0 ,
62+ this . acpContractClient . config . baseFare ,
63+ ) ;
64+
65+ const account = await this . resolveAccount (
66+ subscriptionRequired ,
67+ preferredSubscriptionTier ,
68+ ) ;
69+
70+ const jobId = await this . createJob (
71+ account ,
72+ evaluatorAddress ,
73+ expiredAt ,
74+ fareAmount ,
75+ subscriptionRequired ,
76+ preferredSubscriptionTier ?? "" ,
77+ ) ;
78+
79+ await this . sendInitialMemo ( jobId , fareAmount , subscriptionRequired , {
80+ name : this . name ,
81+ requirement : serviceRequirement ,
82+ priceValue : effectivePrice ,
83+ priceType : effectivePriceType ,
84+ } ) ;
85+
86+ return jobId ;
87+ }
88+
89+ private validateRequest ( serviceRequirement : Object | string ) {
4890 if ( this . providerAddress === this . acpClient . walletAddress ) {
4991 throw new AcpError (
5092 "Provider address cannot be the same as the client address" ,
@@ -53,117 +95,112 @@ class AcpJobOffering {
5395
5496 if ( this . requirement && typeof this . requirement === "object" ) {
5597 const validator = this . ajv . compile ( this . requirement ) ;
56- const valid = validator ( serviceRequirement ) ;
57-
58- if ( ! valid ) {
98+ if ( ! validator ( serviceRequirement ) ) {
5999 throw new AcpError ( this . ajv . errorsText ( validator . errors ) ) ;
60100 }
61101 }
102+ }
62103
63- let account : AcpAccount | null = null ;
64- let subscriptionTier = "" ;
65-
66- // Subscription flow only activates when a tier is explicitly requested
67- // or the offering is a subscription type with tiers available.
104+ private isSubscriptionRequired ( preferredSubscriptionTier ?: string ) : boolean {
68105 const hasSubscriptionTiers = this . subscriptionTiers . length > 0 ;
69- const isSubscriptionOffering =
106+ return (
70107 preferredSubscriptionTier != null ||
71- ( this . priceType === PriceType . SUBSCRIPTION && hasSubscriptionTiers ) ;
108+ ( this . priceType === PriceType . SUBSCRIPTION && hasSubscriptionTiers )
109+ ) ;
110+ }
111+
112+ private validateSubscriptionTier ( preferredSubscriptionTier ?: string ) {
113+ if ( ! preferredSubscriptionTier ) return ;
72114
115+ if ( this . subscriptionTiers . length === 0 ) {
116+ throw new AcpError (
117+ `Offering "${ this . name } " does not support subscription tiers` ,
118+ ) ;
119+ }
120+ if ( ! this . subscriptionTiers . includes ( preferredSubscriptionTier ) ) {
121+ throw new AcpError (
122+ `Preferred subscription tier "${ preferredSubscriptionTier } " is not offered. Available: ${ this . subscriptionTiers . join ( ", " ) } ` ,
123+ ) ;
124+ }
125+ }
126+
127+ /**
128+ * Resolve the account to use for the job.
129+ *
130+ * For non-subscription jobs: returns the existing account if found.
131+ * For subscription jobs, priority:
132+ * 1. Valid account matching preferred tier
133+ * 2. Any valid (non-expired) account
134+ * 3. Expired/unactivated account (expiry = 0) to reuse
135+ * 4. null — createJob will create a new one
136+ */
137+ private async resolveAccount (
138+ subscriptionRequired : boolean ,
139+ preferredSubscriptionTier ?: string ,
140+ ) : Promise < AcpAccount | null > {
73141 const raw = await this . acpClient . getByClientAndProvider (
74142 this . acpContractClient . walletAddress ,
75143 this . providerAddress ,
76144 this . acpContractClient ,
77- isSubscriptionOffering ? this . name : undefined ,
145+ subscriptionRequired ? this . name : undefined ,
78146 ) ;
147+
148+ if ( ! subscriptionRequired ) {
149+ return raw instanceof AcpAccount ? raw : null ;
150+ }
151+
79152 const subscriptionCheck =
80153 raw && typeof raw === "object" && "accounts" in raw
81154 ? ( raw as ISubscriptionCheckResponse )
82155 : null ;
83156
84- console . log ( "raw: " , raw ) ;
157+ if ( ! subscriptionCheck ) return null ;
85158
86- const effectivePriceType = isSubscriptionOffering
87- ? PriceType . SUBSCRIPTION
88- : this . priceType === PriceType . SUBSCRIPTION
89- ? PriceType . FIXED
90- : this . priceType ;
91- const effectivePrice = effectivePriceType === PriceType . SUBSCRIPTION ? 0 : this . price ;
159+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
160+ const allAccounts = subscriptionCheck . accounts ?? [ ] ;
92161
93- const subscriptionRequired = effectivePriceType === PriceType . SUBSCRIPTION ;
162+ const matchedAccount =
163+ this . findPreferredAccount ( allAccounts , preferredSubscriptionTier , now )
164+ ?? allAccounts . find ( ( a ) => a . expiry != null && a . expiry > now )
165+ ?? allAccounts . find ( ( a ) => a . expiry == null || a . expiry === 0 ) ;
94166
95- const finalServiceRequirement : Record < string , any > = {
96- name : this . name ,
97- requirement : serviceRequirement ,
98- priceValue : subscriptionRequired ? 0 : effectivePrice ,
99- priceType : effectivePriceType ,
100- } ;
167+ if ( ! matchedAccount ) return null ;
101168
102- const fareAmount = new FareAmount (
103- effectivePriceType === PriceType . FIXED ? effectivePrice : 0 ,
104- this . acpContractClient . config . baseFare ,
169+ return new AcpAccount (
170+ this . acpContractClient ,
171+ matchedAccount . id ,
172+ matchedAccount . clientAddress ,
173+ matchedAccount . providerAddress ,
174+ matchedAccount . metadata ,
175+ matchedAccount . expiry ,
105176 ) ;
177+ }
106178
107- // Validate preferred tier against offering's subscriptionTiers
108- if ( preferredSubscriptionTier ) {
109- if ( ! hasSubscriptionTiers ) {
110- throw new AcpError (
111- `Offering "${ this . name } " does not support subscription tiers` ,
112- ) ;
113- }
114- if ( ! this . subscriptionTiers . includes ( preferredSubscriptionTier ) ) {
115- throw new AcpError (
116- `Preferred subscription tier "${ preferredSubscriptionTier } " is not offered. Available: ${ this . subscriptionTiers . join ( ", " ) } ` ,
117- ) ;
118- }
119- }
120-
121- // Subscription account selection priority:
122- // 1. Valid account matching preferred tier
123- // 2. Any valid account
124- // 3. Expired account (expiry = 0), reuse it
125- // 4. No account — createJob will create a new one
126-
127- if ( ! subscriptionRequired ) {
128- account = raw instanceof AcpAccount ? raw : null ;
129- } else if ( subscriptionCheck ) {
130- const now = Math . floor ( Date . now ( ) / 1000 ) ;
131- const allAccounts = subscriptionCheck . accounts ?? [ ] ;
132-
133- const preferredValidAccount = preferredSubscriptionTier
134- ? allAccounts . find ( ( a ) => {
135- if ( a . expiry == null || a . expiry <= now ) return false ;
136- const meta =
137- typeof a . metadata === "string"
138- ? ( ( ) => { try { return JSON . parse ( a . metadata ) ; } catch { return { } ; } } ) ( )
139- : ( a . metadata ?? { } ) ;
140- return meta ?. name === preferredSubscriptionTier ;
141- } )
142- : undefined ;
143-
144- const anyValidAccount = preferredValidAccount
145- ?? allAccounts . find ( ( a ) => a . expiry != null && a . expiry > now ) ;
146-
147- const reusableAccount = anyValidAccount
148- ?? allAccounts . find ( ( a ) => a . expiry == null || a . expiry === 0 ) ;
149-
150- if ( reusableAccount ) {
151- account = new AcpAccount (
152- this . acpContractClient ,
153- reusableAccount . id ,
154- reusableAccount . clientAddress ,
155- reusableAccount . providerAddress ,
156- reusableAccount . metadata ,
157- reusableAccount . expiry ,
158- ) ;
159- } else {
160- subscriptionTier = preferredSubscriptionTier ?? "" ;
161- }
162- } else {
163- // No accounts at all — new account will be created by createJob
164- subscriptionTier = preferredSubscriptionTier ?? "" ;
165- }
179+ private findPreferredAccount (
180+ accounts : IAcpAccount [ ] ,
181+ preferredTier : string | undefined ,
182+ now : number ,
183+ ) : IAcpAccount | undefined {
184+ if ( ! preferredTier ) return undefined ;
185+
186+ return accounts . find ( ( a ) => {
187+ if ( a . expiry == null || a . expiry <= now ) return false ;
188+ const meta =
189+ typeof a . metadata === "string"
190+ ? ( ( ) => { try { return JSON . parse ( a . metadata ) ; } catch { return { } ; } } ) ( )
191+ : ( a . metadata ?? { } ) ;
192+ return meta ?. name === preferredTier ;
193+ } ) ;
194+ }
166195
196+ private async createJob (
197+ account : AcpAccount | null ,
198+ evaluatorAddress : Address | undefined ,
199+ expiredAt : Date ,
200+ fareAmount : FareAmount ,
201+ subscriptionRequired : boolean ,
202+ subscriptionTier : string ,
203+ ) : Promise < number > {
167204 const isV1 = [
168205 baseSepoliaAcpConfig . contractAddress ,
169206 baseSepoliaAcpX402Config . contractAddress ,
@@ -173,80 +210,77 @@ class AcpJobOffering {
173210
174211 const chainId = this . acpContractClient . config . chain
175212 . id as keyof typeof USDC_TOKEN_ADDRESS ;
176-
177213 const isUsdcPaymentToken =
178214 USDC_TOKEN_ADDRESS [ chainId ] . toLowerCase ( ) ===
179215 fareAmount . fare . contractAddress . toLowerCase ( ) ;
180-
181216 const isX402Job =
182217 this . acpContractClient . config . x402Config && isUsdcPaymentToken ;
183218
184- // For subscription jobs, include tier name only as account metadata (before payment)
219+ const budget = subscriptionRequired ? 0n : fareAmount . amount ;
185220 const subscriptionMetadata = JSON . stringify ( { name : subscriptionTier } ) ;
186221
187- const createJobOperations : OperationPayload [ ] = [ ] ;
188-
189- if ( isV1 || ! account ) {
190- createJobOperations . push (
191- this . acpContractClient . createJob (
192- this . providerAddress ,
193- evaluatorAddress || this . acpContractClient . walletAddress ,
194- expiredAt ,
195- fareAmount . fare . contractAddress ,
196- subscriptionRequired ? 0n : fareAmount . amount ,
197- subscriptionMetadata ,
198- isX402Job ,
199- ) ,
200- ) ;
201- } else {
202- createJobOperations . push (
203- this . acpContractClient . createJobWithAccount (
204- account . id ,
205- evaluatorAddress || zeroAddress ,
206- subscriptionRequired ? 0n : fareAmount . amount ,
207- fareAmount . fare . contractAddress ,
208- expiredAt ,
209- isX402Job ,
210- ) ,
211- ) ;
212- }
213-
214- const { userOpHash } =
215- await this . acpContractClient . handleOperation ( createJobOperations ) ;
216-
217- const jobId = await this . acpContractClient . getJobId (
222+ const operation =
223+ isV1 || ! account
224+ ? this . acpContractClient . createJob (
225+ this . providerAddress ,
226+ evaluatorAddress || this . acpContractClient . walletAddress ,
227+ expiredAt ,
228+ fareAmount . fare . contractAddress ,
229+ budget ,
230+ subscriptionMetadata ,
231+ isX402Job ,
232+ )
233+ : this . acpContractClient . createJobWithAccount (
234+ account . id ,
235+ evaluatorAddress || zeroAddress ,
236+ budget ,
237+ fareAmount . fare . contractAddress ,
238+ expiredAt ,
239+ isX402Job ,
240+ ) ;
241+
242+ const { userOpHash } = await this . acpContractClient . handleOperation ( [
243+ operation ,
244+ ] ) ;
245+
246+ return this . acpContractClient . getJobId (
218247 userOpHash ,
219248 this . acpContractClient . walletAddress ,
220249 this . providerAddress ,
221250 ) ;
251+ }
222252
253+ private async sendInitialMemo (
254+ jobId : number ,
255+ fareAmount : FareAmount ,
256+ subscriptionRequired : boolean ,
257+ serviceRequirement : Record < string , any > ,
258+ ) {
223259 const payloads : OperationPayload [ ] = [ ] ;
224- const setBudgetWithPaymentTokenPayload =
225- this . acpContractClient . setBudgetWithPaymentToken (
226- jobId ,
227- fareAmount . amount ,
228- fareAmount . fare . contractAddress ,
229- ) ;
230260
231261 if ( ! subscriptionRequired ) {
232- if ( setBudgetWithPaymentTokenPayload ) {
233- payloads . push ( setBudgetWithPaymentTokenPayload ) ;
262+ const setBudgetPayload =
263+ this . acpContractClient . setBudgetWithPaymentToken (
264+ jobId ,
265+ fareAmount . amount ,
266+ fareAmount . fare . contractAddress ,
267+ ) ;
268+ if ( setBudgetPayload ) {
269+ payloads . push ( setBudgetPayload ) ;
234270 }
235271 }
236272
237273 payloads . push (
238274 this . acpContractClient . createMemo (
239275 jobId ,
240- JSON . stringify ( finalServiceRequirement ) ,
276+ JSON . stringify ( serviceRequirement ) ,
241277 MemoType . MESSAGE ,
242278 true ,
243279 AcpJobPhases . NEGOTIATION ,
244280 ) ,
245281 ) ;
246282
247283 await this . acpContractClient . handleOperation ( payloads ) ;
248-
249- return jobId ;
250284 }
251285}
252286
0 commit comments