Skip to content

Commit 6471e60

Browse files
authored
feat: proposal for shortcuts (#69)
### Description This is a proposal for adding support for shortcut hooks. ### Details This is focusing on applying shortcuts to positions. But it could be extended to support shortcuts for tokens or more generic actions. It would support the following use cases - claiming Good Dollar UBI - claiming Ubeswap farm staking rewards The shortcuts are defined in a new file under `src/apps/{appId}/shortcuts.ts`. This is to give builders clear visibility into what type of hooks are supported for a given app. The plan is to rename `plugin.ts` to `positions.ts` to follow with this new structure. The positions are extended with a new `availableShortcutIds` property which allows defining which shortcuts can be applied to them. Underlying tokens get a new optional `category` property which allows giving more meaning to what they are. Specifically here, the `claimable` category tells the wallet the tokens and amounts which can be claimed. The wallet would fetch the shortcuts with a new route in `hooks-api`. (Or we could also return both positions and shortcuts data in a single call). Using the new data in the returned positions, the wallet would be able to fill the content of a new "Claim Rewards" screen. Displaying details for the claimable tokens. Tapping the button next to each of them would call the shortcut's `onTrigger` function, via a new route in `hooks-api`. It would return 0, 1 or more transaction to sign. The wallet would take it from there and display the usual transaction signing bottom sheet. Similar to what's happening when interacting with dapps with WalletConnect. ### Future considerations This could be extended to support other types of shortcuts related to positions and requiring additional input parameters. For instance adding/removing liquidity in Ubeswap pools. This would require a new wallet UI to specify the amounts needed. We could maybe think of pre-built UI elements that could be used and defined by shortcuts so we don't have to build custom UIs in the wallet to support them. ### Related linear task Fixes RET-732
1 parent 02f5a82 commit 6471e60

14 files changed

+282
-4
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"test:e2e": "jest --selectProjects e2e",
2727
"supercheck": "yarn format && yarn lint:fix && yarn typecheck && yarn test",
2828
"getPositions": "ts-node ./scripts/getPositions.ts",
29+
"getShortcuts": "ts-node ./scripts/getShortcuts.ts",
30+
"triggerShortcut": "ts-node ./scripts/triggerShortcut.ts",
2931
"release": "semantic-release"
3032
},
3133
"dependencies": {

scripts/getPositions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ void (async () => {
6565
balance: balance.toFixed(2),
6666
balanceUsd: balance.times(position.priceUsd).toFixed(2),
6767
breakdown: position.tokens.map(breakdownToken).join(', '),
68+
availableShortcutIds: position.availableShortcutIds,
6869
}
6970
} else {
7071
return {
@@ -75,6 +76,7 @@ void (async () => {
7576
title: `${position.displayProps.title} (${position.displayProps.description})`,
7677
balanceUsd: new BigNumber(position.balanceUsd).toFixed(2),
7778
breakdown: position.tokens.map(breakdownToken).join(', '),
79+
availableShortcutIds: position.availableShortcutIds,
7880
}
7981
}
8082
}),

scripts/getShortcuts.e2e.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as $ from 'shelljs'
2+
3+
describe('getShortcuts', () => {
4+
it('should get shortcuts successfully', () => {
5+
const result = $.exec('yarn getShortcuts')
6+
expect(result.code).toBe(0)
7+
})
8+
})

scripts/getShortcuts.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Helper script to get the shortcuts
2+
/* eslint-disable no-console */
3+
import yargs from 'yargs'
4+
import { getShortcuts } from '../src/getShortcuts'
5+
6+
const argv = yargs(process.argv.slice(2))
7+
.usage('Usage: $0 --apps app1[,app2]')
8+
.options({
9+
apps: {
10+
alias: 'p',
11+
describe: 'App IDs to get shortcuts for, defaults to all',
12+
type: 'array',
13+
default: [],
14+
// Allows comma separated values
15+
coerce: (array: string[]) => {
16+
return array.flatMap((v) => v.split(','))
17+
},
18+
},
19+
})
20+
.parseSync()
21+
22+
void (async () => {
23+
const shortcuts = await getShortcuts(argv.apps)
24+
console.log('shortcuts', JSON.stringify(shortcuts, null, ' '))
25+
26+
console.table(shortcuts)
27+
})()

scripts/triggerShortcut.e2e.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as $ from 'shelljs'
2+
3+
describe('triggerShortcut', () => {
4+
it('should trigger a shortcut successfully', () => {
5+
const result = $.exec(
6+
'yarn triggerShortcut --network celo --address 0x2b8441ef13333ffa955c9ea5ab5b3692da95260d --app ubeswap --shortcut claim-reward --positionAddress 0x',
7+
)
8+
expect(result.code).toBe(0)
9+
})
10+
})

scripts/triggerShortcut.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Helper script to trigger a shortcut
2+
/* eslint-disable no-console */
3+
import yargs from 'yargs'
4+
import { getShortcuts } from '../src/getShortcuts'
5+
6+
const argv = yargs(process.argv.slice(2))
7+
.usage(
8+
'Usage: $0 --network <network> --address <address> --app <appId> --shortcut <shortcutId> --positionAddress <positionAddress>',
9+
)
10+
.options({
11+
network: {
12+
alias: 'n',
13+
describe: 'Network to get positions for',
14+
choices: ['celo', 'celoAlfajores'],
15+
default: 'celo',
16+
},
17+
address: {
18+
alias: 'a',
19+
describe: 'Address to get positions for',
20+
type: 'string',
21+
demandOption: true,
22+
},
23+
app: {
24+
alias: 'p',
25+
describe: 'App ID of the shortcut to trigger',
26+
type: 'string',
27+
demandOption: true,
28+
},
29+
shortcut: {
30+
alias: 's',
31+
describe: 'Shortcut ID to trigger',
32+
type: 'string',
33+
demandOption: true,
34+
},
35+
positionAddress: {
36+
describe: 'Position address to trigger the shortcut on',
37+
type: 'string',
38+
demandOption: true,
39+
},
40+
})
41+
.parseSync()
42+
43+
void (async () => {
44+
const shortcuts = await getShortcuts([argv.app])
45+
46+
const shortcut = shortcuts.find((s) => s.id === argv.shortcut)
47+
if (!shortcut) {
48+
throw new Error(
49+
`No shortcut found with id '${
50+
argv.shortcut
51+
}', available shortcuts: ${shortcuts.map((s) => s.id).join(', ')}`,
52+
)
53+
}
54+
55+
console.log(
56+
`Triggering shortcut '${shortcut.id}' for app '${shortcut.appId}'`,
57+
{
58+
network: argv.network,
59+
address: argv.address,
60+
positionAddress: argv.positionAddress,
61+
},
62+
)
63+
const result = await shortcut.onTrigger(
64+
argv.network,
65+
argv.address,
66+
argv.positionAddress,
67+
)
68+
69+
console.table(result)
70+
})()

src/apps/ubeswap/plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ async function getFarmPositionDefinitions(
196196
{
197197
address: farm.rewardsTokenAddress.toLowerCase(),
198198
network,
199+
category: 'claimable',
199200
},
200201
],
201202
displayProps: ({ resolvedTokens }) => {
@@ -208,7 +209,7 @@ async function getFarmPositionDefinitions(
208209
imageUrl: poolToken.displayProps.imageUrl,
209210
}
210211
},
211-
212+
availableShortcutIds: ['claim-reward'],
212213
balances: async ({ resolvedTokens }) => {
213214
const poolToken = resolvedTokens[farm.lpAddress.toLowerCase()]
214215
const share = new BigNumber(farm.balance.toString()).div(

src/apps/ubeswap/shortcuts.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ShortcutsHook } from '../../shortcuts'
2+
3+
const hook: ShortcutsHook = {
4+
getShortcutDefinitions() {
5+
return [
6+
{
7+
id: 'claim-reward',
8+
name: 'Claim',
9+
description: 'Claim rewards for staked liquidity',
10+
networks: ['celo'],
11+
category: 'claim',
12+
async onTrigger(_network, _address, _positionAddress) {
13+
// TODO: return the unsigned transaction to claim the reward
14+
return []
15+
},
16+
},
17+
]
18+
},
19+
}
20+
21+
export default hook

src/getHooks.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { promises as fs } from 'fs'
2+
import path from 'path'
3+
import { AppPlugin } from './plugin'
4+
import { ShortcutsHook } from './shortcuts'
5+
6+
type HookTypeName = 'positions' | 'shortcuts'
7+
8+
type HookType<T> = T extends 'positions'
9+
? AppPlugin
10+
: T extends 'shortcuts'
11+
? ShortcutsHook
12+
: never
13+
14+
const APP_ID_PATTERN = /^[a-zA-Z0-9-]+$/
15+
16+
async function getAllAppIds(): Promise<string[]> {
17+
// Read all folders from the "apps" folder
18+
const files = await fs.readdir(path.join(__dirname, 'apps'), {
19+
withFileTypes: true,
20+
})
21+
const folders = files.filter((file) => file.isDirectory())
22+
// Check that all folders are valid app ids
23+
for (const folder of folders) {
24+
if (!APP_ID_PATTERN.test(folder.name)) {
25+
throw new Error(
26+
`Invalid app id: '${folder.name}', must match ${APP_ID_PATTERN}`,
27+
)
28+
}
29+
}
30+
return folders.map((folder) => folder.name)
31+
}
32+
33+
export async function getHooks<T extends HookTypeName>(
34+
appIds: string[],
35+
hookType: T,
36+
): Promise<Record<string, HookType<T>>> {
37+
const allAppIds = await getAllAppIds()
38+
const plugins: Record<string, HookType<T>> = {}
39+
const appIdsToLoad = appIds.length === 0 ? allAppIds : appIds
40+
for (const appId of appIdsToLoad) {
41+
if (!allAppIds.includes(appId)) {
42+
throw new Error(
43+
`No app with id '${appId}' found, available apps: ${allAppIds.join(
44+
', ',
45+
)}`,
46+
)
47+
}
48+
49+
let plugin: any
50+
try {
51+
plugin = await import(`./apps/${appId}/${hookType}`)
52+
} catch (e) {
53+
if (appIds.includes(appId)) {
54+
if ((e as any).code === 'MODULE_NOT_FOUND') {
55+
throw new Error(
56+
`No ${hookType} hook found for app '${appId}', make sure to export a default hook from 'src/apps/${appId}/${hookType}.ts'`,
57+
)
58+
}
59+
throw e
60+
}
61+
}
62+
if (plugin?.default) {
63+
plugins[appId] = plugin.default
64+
}
65+
}
66+
return plugins
67+
}

src/getPositions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ async function resolveAppTokenPosition(
263263
supply: toSerializedDecimalNumber(
264264
toDecimalNumber(totalSupply, positionTokenInfo.decimals),
265265
),
266+
availableShortcutIds: positionDefinition.availableShortcutIds ?? [],
266267
}
267268

268269
return position
@@ -286,7 +287,10 @@ async function resolveContractPosition(
286287

287288
const tokens = positionDefinition.tokens.map((token, i) =>
288289
tokenWithUnderlyingBalance(
289-
resolvedTokens[token.address],
290+
{
291+
...resolvedTokens[token.address],
292+
...(token.category && { category: token.category }),
293+
},
290294
balances[i],
291295
new BigNumber(1) as DecimalNumber,
292296
),
@@ -311,6 +315,7 @@ async function resolveContractPosition(
311315
displayProps,
312316
tokens: tokens,
313317
balanceUsd: toSerializedDecimalNumber(balanceUsd),
318+
availableShortcutIds: positionDefinition.availableShortcutIds ?? [],
314319
}
315320

316321
return position

0 commit comments

Comments
 (0)