Skip to content

Commit

Permalink
coupon service
Browse files Browse the repository at this point in the history
  • Loading branch information
rajranjan0608 committed Oct 25, 2023
1 parent 303a202 commit f04abb5
Show file tree
Hide file tree
Showing 11 changed files with 2,728 additions and 106 deletions.
155 changes: 155 additions & 0 deletions CouponService/couponService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { DynamoDBDocumentClient, UpdateCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"
import { Mutex, MutexInterface } from 'async-mutex'

type Coupon = {
id: string,
maxLimitAmount: number,
consumedAmount: number,
expiry: number,
}

type CouponConfig = {
IS_ENABLED: boolean,
MAX_LIMIT_CAP: number,
}

function validateCouponData(coupon: any, couponConfig: CouponConfig): Coupon | undefined {
if (
coupon.id &&
coupon.maxLimitAmount > 0 &&
coupon.maxLimitAmount <= couponConfig.MAX_LIMIT_CAP &&
coupon.consumedAmount <= coupon.maxLimitAmount &&
coupon.expiry > 0
) {
return coupon
}

return undefined
}

export class CouponService {
private readonly mutex: MutexInterface
private readonly documentClient?: DynamoDBDocumentClient
private readonly couponConfig: CouponConfig
coupons: Map<string, Coupon>

constructor(couponConfig: CouponConfig) {
this.mutex = new Mutex()
this.coupons = new Map<string, Coupon>()
this.couponConfig = couponConfig

// Return early if coupon system is disabled
if (!couponConfig.IS_ENABLED) return

const ddbClient = new DynamoDBClient({ region: 'us-east-1' })
this.documentClient = DynamoDBDocumentClient.from(ddbClient)

this.syncCoupons()

// Syncs coupon between DynamoDB and memory at regular intervals
setInterval(() => {
this.syncCoupons()
}, 10_000)
}

/**
* Syncs coupons in memory with database
* 1. Fetches new coupons from database into memory
* 2. Remove coupons which were deleted in database from memory
* 3. Updates coupon usage limits in database
* 4. TODO(raj): Delete expired (or few days after expiry) coupons from database
*/
private async syncCoupons(): Promise<void> {
const params = new ScanCommand({
TableName: 'coupons',
})

const result = await this.documentClient?.send(params)

// Required for quick lookup of coupons in DB fetched list
const dbItemSet = new Set<string>()

// Fetches new coupons from database into memory
result?.Items?.forEach((item: Record<string, any>) => {
const coupon: Coupon | undefined = validateCouponData(item, this.couponConfig)
if (coupon) {
dbItemSet.add(coupon.id)

// Only load new coupons into memory
if (this.coupons.get(coupon.id) === undefined) {
this.coupons.set(coupon.id, coupon)
}
} else {
console.log("fetched invalid coupon data:", item)
}
})

// Remove coupons which were deleted in database from memory
for (const [id, _item] of this.coupons.entries()) {
if (!dbItemSet.has(id)) {
this.coupons.delete(id)
}
}

// Updates coupon usage limits in database
await this.batchUpdateCoupons()
}

// Iterates over every coupon in memory and updates database with their `consumedAmount`
async batchUpdateCoupons(): Promise<void> {
this.coupons.forEach(async (couponItem, _id) => {
const updateRequest = {
TableName: 'coupons',
Key: {
id: couponItem.id,
},
UpdateExpression: 'SET consumedAmount = :consumedAmount',
ExpressionAttributeValues: {
':consumedAmount': couponItem.consumedAmount,
},
}

const params = new UpdateCommand(updateRequest)
await this.documentClient?.send(params)
})
}

async consumeCouponAmount(id: string, amount: number): Promise<boolean> {
// Return `true` early, if coupon system is disabled (for debugging)
if (!this.couponConfig.IS_ENABLED) return true

const release = await this.mutex.acquire()
try {
const coupon = this.coupons.get(id)
if (
coupon &&
coupon.expiry > (Date.now() / 1000) &&
coupon.consumedAmount + amount < coupon.maxLimitAmount
) {
coupon.consumedAmount += amount
return true
}
return false
} finally {
release()
}
}

async reclaimCouponAmount(id: string, amount: number): Promise<void> {
const release = await this.mutex.acquire()

try {
const coupon = this.coupons.get(id)
if (
coupon &&
coupon.expiry > (Date.now() / 1000) &&
coupon.consumedAmount - amount > 0
) {
coupon.consumedAmount -= amount
}
} finally {
release()
}
}
}
53 changes: 44 additions & 9 deletions client/src/components/FaucetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ const FaucetForm = (props: any) => {
const [token, setToken] = useState<number | null>(null)
const [widgetID, setwidgetID] = useState(new Map())
const [recaptcha, setRecaptcha] = useState<ReCaptcha | undefined>(undefined)
const [isV2, setIsV2] = useState<boolean>(false)
const [isV2, setIsV2] = useState<boolean>(true)
const [chainConfigs, setChainConfigs] = useState<any>([])
const [inputAddress, setInputAddress] = useState<string>("")
const [couponId, setCouponId] = useState<string>("")
const [address, setAddress] = useState<string | null>(null)
const [faucetAddress, setFaucetAddress] = useState<string | null>(null)
const [options, setOptions] = useState<DropdownOption[]>([])
Expand All @@ -42,9 +43,17 @@ const FaucetForm = (props: any) => {
))
updateChainConfigs()
connectAccount(updateAddress, false)

}, [])

useEffect(() => {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
setIsV2(true)
recaptcha?.loadV2Captcha(props.config.V2_SITE_KEY, widgetID)
})
}
}, [props.config.V2_SITE_KEY, recaptcha, widgetID, window.grecaptcha])

// Update balance whenver chain changes or after transaction is processed
useEffect(() => {
updateBalance()
Expand Down Expand Up @@ -182,7 +191,7 @@ const FaucetForm = (props: any) => {
function getChainParams(): {chain: string, erc20: string} {
let params = {
chain: chainConfigs[chain!]?.ID,
erc20: chainConfigs[token!]?.ID
erc20: chainConfigs[chain!]?.ID === chainConfigs[token!]?.ID ? undefined : chainConfigs[token!]?.ID
}

return params
Expand Down Expand Up @@ -229,11 +238,21 @@ const FaucetForm = (props: any) => {
}
}

function calculateBaseUnit(amount: string = "0", decimals: number = 18): BigInt {
for(let i = 0; i < decimals; i++) {
amount += "0"
function calculateBaseUnit(amount: string = "0", decimals: number = 18): bigint {
const parsedNumber = parseFloat(amount);

if (!isFinite(parsedNumber)) {
throw new Error("Invalid number input for formatting base unit: " + amount);
}
return BigInt(amount)

const formattedNumber = parsedNumber.toFixed(decimals);
const [integerPart, decimalPart] = formattedNumber.split('.');

const bigInteger = BigInt(integerPart);
const bigDecimal = BigInt(decimalPart || '0');

const finalAmount = bigInteger * BigInt(10 ** decimals) + bigDecimal;
return finalAmount
}

function calculateLargestUnit(amount: string = "0", decimals: number = 18): string {
Expand Down Expand Up @@ -275,6 +294,10 @@ const FaucetForm = (props: any) => {
}
}

function updateCouponId(couponId: any): void {
setCouponId(couponId!)
}

async function getCaptchaToken(index: number = 0): Promise<{token?:string, v2Token?: string}> {
const { token, v2Token } = await recaptcha!.getToken(isV2, widgetID, index)
return { token, v2Token }
Expand Down Expand Up @@ -324,7 +347,8 @@ const FaucetForm = (props: any) => {
token,
v2Token,
chain,
erc20
erc20,
couponId
})
data = response?.data
} catch(err: any) {
Expand Down Expand Up @@ -443,7 +467,7 @@ const FaucetForm = (props: any) => {
)

const resetRecaptcha = (): void => {
setIsV2(false)
// setIsV2(false)
recaptcha!.resetV2Captcha(widgetID)
}

Expand Down Expand Up @@ -521,6 +545,17 @@ const FaucetForm = (props: any) => {
Connect
</span>
</div>

<br/>

<div className='address-input'>
<input
placeholder='Coupon ID (optional)'
value={couponId || ""}
onChange={(e) => updateCouponId(e.target.value)}
/>
</div>

<span className='rate-limit-text' style={{color: "red"}}>{sendTokenResponse?.message}</span>

<div className='v2-recaptcha' style={{marginTop: "10px"}}></div>
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/ReCaptcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default class ReCaptcha {
this.setWidgetID = setWidgetID
}

async getToken(isV2 = false, widgetID: any, index: number = 0): Promise<{token?: string, v2Token?: string}> {
async getToken(isV2 = true, widgetID: any, index: number = 0): Promise<{token?: string, v2Token?: string}> {
let token = "", v2Token = ""
!isV2 && await window.grecaptcha.execute(this.siteKey, {action: this.action})
.then((res: string) => {
Expand All @@ -39,7 +39,7 @@ export default class ReCaptcha {
if(widgetID.get(index) || widgetID.get(index) == 0) {
window.grecaptcha.reset(widgetID.get(index))
}
v2CaptchaContainer.style.display = "none"
// v2CaptchaContainer.style.display = "none"
}
}

Expand All @@ -53,7 +53,7 @@ export default class ReCaptcha {
}
} else {
v2CaptchaContainer.style.display = "block"
const newWidgetID = window.grecaptcha.render(v2CaptchaContainer, {
const newWidgetID = window.grecaptcha?.render(v2CaptchaContainer, {
'sitekey' : v2siteKey,
'theme': 'dark'
})
Expand Down
13 changes: 9 additions & 4 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
}
},
"NATIVE_CLIENT": true,
"DEBUG": true,
"DEBUG": false,
"couponConfig": {
"IS_ENABLED": false,
"MAX_LIMIT_CAP": 5000
},
"evmchains": [
{
"ID": "C",
Expand All @@ -22,12 +26,13 @@
"IMAGE": "https://glacier-api.avax.network/proxy/chain-assets/main/chains/43113/chain-logo.png",
"MAX_PRIORITY_FEE": "10000000000",
"MAX_FEE": "100000000000",
"DRIP_AMOUNT": 2,
"DRIP_AMOUNT": 0.01,
"DECIMALS": 18,
"RECALIBRATE": 30,
"COUPON_REQUIRED": true,
"RATELIMIT": {
"MAX_LIMIT": 1,
"WINDOW_SIZE": 1440
"MAX_LIMIT": 5,
"WINDOW_SIZE": 60
}
},
{
Expand Down
Loading

0 comments on commit f04abb5

Please sign in to comment.