Skip to content
This repository was archived by the owner on Feb 8, 2025. It is now read-only.

Commit bd03dca

Browse files
authored
Merge pull request #277 from zallo-labs/Z-330-proposal-notifications
Z 330 proposal notifications
2 parents 9c9bee2 + 7aa96e2 commit bd03dca

25 files changed

+301
-195
lines changed

.easignore

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

44
## Unnecessary files
55
/api
6-
/site
7-
# contracts is required by lib
8-
# packages/* are required
6+
/contracts
7+
/docs
8+
# packages/{chain,lib} are required

api/dbschema/edgeql-js/__spec__.ts

+70-70
Large diffs are not rendered by default.

api/dbschema/edgeql-js/modules/default.ts

+77-77
Large diffs are not rendered by default.

api/src/core/expo/expo.service.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ export class ExpoService {
1111
constructor(private db: DatabaseService) {}
1212

1313
async sendNotification(messages: (Omit<ExpoPushMessage, 'to'> & { to: ExpoPushToken })[]) {
14-
const responses = (await this.expo.sendPushNotificationsAsync(messages)).map((ticket, i) => ({
15-
ticket,
14+
if (!messages.length) return;
15+
16+
const responses = (await this.expo.sendPushNotificationsAsync(messages)).map((message, i) => ({
17+
ticket: message,
1618
to: messages[i].to,
1719
}));
1820

api/src/feat/policies/existing-policies.edgeql

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ with account := (select Account filter .address = <UAddress>$account),
22
keys := array_unpack(<array<uint16>>$policyKeys)
33
select Policy {
44
key,
5+
name,
56
approvers: { address },
67
threshold,
78
actions: {

api/src/feat/policies/existing-policies.query.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type ExistingPoliciesArgs = {
99

1010
export type ExistingPoliciesReturns = Array<{
1111
"key": number;
12+
"name": string;
1213
"approvers": Array<{
1314
"address": string;
1415
}>;
@@ -46,6 +47,7 @@ with account := (select Account filter .address = <UAddress>$account),
4647
keys := array_unpack(<array<uint16>>$policyKeys)
4748
select Policy {
4849
key,
50+
name,
4951
approvers: { address },
5052
threshold,
5153
actions: {

api/src/feat/policies/policies.events.ts

+3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export class PoliciesEventsProcessor {
4747
}
4848

4949
private async markStateAsActive(chain: Chain, log: Log, key: PolicyKey) {
50+
// FIXME: when multiple policies are activated in one block, the wrong one may be marked as active
51+
// This *always* occurs when a policy is activated by a policy update transaction
52+
5053
const account = asUAddress(log.address, chain);
5154
const r = await this.db.exec(activatePolicy, {
5255
account,

api/src/feat/policies/policies.resolver.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class PoliciesResolver {
5151

5252
@Mutation(() => [Policy])
5353
async proposePolicies(@Input() input: ProposePoliciesInput, @Info() info: GraphQLResolveInfo) {
54-
const policies = await this.service.propose(input);
54+
const policies = await this.service.propose(input, ...input.policies);
5555
return this.service.policies(
5656
policies.map((p) => p.id),
5757
getShape(info),

api/src/feat/policies/policies.service.ts

+23-16
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ import {
88
validateMessage,
99
validateTransaction,
1010
Policy,
11-
Address,
1211
UAddress,
13-
PLACEHOLDER_ACCOUNT_ADDRESS,
1412
Tx,
1513
UUID,
1614
asUUID,
@@ -31,10 +29,9 @@ import { ShapeFunc } from '~/core/database';
3129
import {
3230
policyStateAsPolicy,
3331
PolicyShape,
34-
policyInputAsStateShape,
32+
inputAsPolicyState,
3533
selectPolicy,
3634
latestPolicy2,
37-
inputAsPolicy,
3835
} from './policies.util';
3936
import { NameTaken, PolicyEvent, Policy as PolicyModel, ValidationError } from './policies.model';
4037
import { TX_SHAPE, transactionAsTx, ProposalTxShape } from '../transactions/transactions.util';
@@ -119,16 +116,25 @@ export class PoliciesService {
119116
const currentPolicies = await this.db.exec(existingPolicies, { account, policyKeys });
120117
const changedPolicies = policiesWithKeys
121118
.map((input) => {
122-
const policy = inputAsPolicy(input.key, input);
123-
const existing = currentPolicies.find((p) => p.key === policy.key);
124-
return (
125-
(!existing || encodePolicy(policy) !== encodePolicy(policyStateAsPolicy(existing))) && {
126-
input,
127-
policy,
128-
}
129-
);
119+
// Merge exisiting policy state (draft or latest) with input
120+
const existing = currentPolicies.find((p) => p.key === input.key);
121+
const policyState = inputAsPolicyState(input.key, input, existing);
122+
const policy = policyStateAsPolicy(policyState);
123+
124+
// Ignore unchanged policies
125+
if (existing && encodePolicy(policy) === encodePolicy(policyStateAsPolicy(existing)))
126+
return;
127+
128+
return {
129+
policy,
130+
state: {
131+
name: 'Policy ' + input.key,
132+
...policyState,
133+
},
134+
};
130135
})
131136
.filter(Boolean);
137+
if (!changedPolicies.length) return [];
132138

133139
// Propose transaction with policy inserts
134140
const transaction = !isInitialization
@@ -151,15 +157,16 @@ export class PoliciesService {
151157
await this.db.exec(insertPolicies, {
152158
account,
153159
transaction,
154-
policies: changedPolicies.map(({ input }) => ({
160+
policies: changedPolicies.map(({ state }) => ({
155161
...(isInitialization && { activationBlock: 0n }),
156-
...policyInputAsStateShape(input.key, input),
157-
name: input.name || 'Policy ' + input.key,
162+
...state,
158163
})),
159164
})
160165
).map((p) => ({ ...p, id: asUUID(p.id), key: asPolicyKey(p.key) }));
161166

162-
const approvers = new Set(changedPolicies.flatMap(({ input }) => input.approvers));
167+
const approvers = new Set(
168+
changedPolicies.flatMap(({ state }) => state.approvers.map((a) => asAddress(a.address))),
169+
);
163170
this.userAccounts.invalidateApproversCache(...approvers);
164171

165172
newPolicies.forEach(({ id }) =>

api/src/feat/policies/policies.util.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export const policyStateAsPolicy = <S extends PolicyShape>(state: S) =>
140140
})
141141
: null) as S extends null ? Policy | null : Policy;
142142

143-
export const policyInputAsStateShape = (
143+
export const inputAsPolicyState = (
144144
key: PolicyKey,
145145
p: Partial<PolicyInput>,
146146
defaults: NonNullable<PolicyShape> = {
@@ -183,7 +183,7 @@ export const policyInputAsStateShape = (
183183
};
184184

185185
export const inputAsPolicy = (key: PolicyKey, p: PolicyInput) =>
186-
policyStateAsPolicy(policyInputAsStateShape(key, p));
186+
policyStateAsPolicy(inputAsPolicyState(key, p));
187187

188188
export const asTransfersConfig = (c: TransfersConfigInput): TransfersConfig => ({
189189
defaultAllow: c.defaultAllow,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
with p := (select Proposal filter .id = <uuid>$proposal),
2+
approvers := p.policy.approvers,
3+
responses := count((select p.<proposal[is ProposalResponse] limit 2)),
4+
shouldNotify := ((responses = 1) if p.proposedBy in approvers else (responses = 0)),
5+
approversToNotify := (approvers if shouldNotify else {})
6+
select {
7+
isTransaction := exists [p is Transaction],
8+
approvers := (
9+
select approversToNotify {
10+
pushToken := .details.pushToken
11+
} filter exists .pushToken
12+
)
13+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// GENERATED by @edgedb/generate v0.5.3
2+
3+
import type {Executor} from "edgedb";
4+
5+
export type ApproversToNotifyArgs = {
6+
readonly "proposal": string;
7+
};
8+
9+
export type ApproversToNotifyReturns = {
10+
"isTransaction": boolean;
11+
"approvers": Array<{
12+
"pushToken": string | null;
13+
}>;
14+
};
15+
16+
export function approversToNotify(client: Executor, args: ApproversToNotifyArgs): Promise<ApproversToNotifyReturns> {
17+
return client.queryRequiredSingle(`\
18+
with p := (select Proposal filter .id = <uuid>$proposal),
19+
approvers := p.policy.approvers,
20+
responses := count((select p.<proposal[is ProposalResponse] limit 2)),
21+
shouldNotify := ((responses = 1) if p.proposedBy in approvers else (responses = 0)),
22+
approversToNotify := (approvers if shouldNotify else {})
23+
select {
24+
isTransaction := exists [p is Transaction],
25+
approvers := (
26+
select approversToNotify {
27+
pushToken := .details.pushToken
28+
} filter exists .pushToken
29+
)
30+
};`, args);
31+
32+
}

api/src/feat/proposals/proposals.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Module } from '@nestjs/common';
22
import { ProposalsService } from './proposals.service';
33
import { ProposalsResolver } from './proposals.resolver';
4+
import { ExpoModule } from '~/core/expo/expo.module';
45

56
@Module({
7+
imports: [ExpoModule],
68
providers: [ProposalsService, ProposalsResolver],
79
exports: [ProposalsService],
810
})

api/src/feat/proposals/proposals.service.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { rejectProposal } from './reject-proposal.query';
1717
import { approveProposal } from './approve-proposal.query';
1818
import { UserInputError } from '@nestjs/apollo';
1919
import { deleteResponse } from './delete-response.query';
20+
import { approversToNotify } from './approvers-to-notify.query';
21+
import { ExpoService } from '~/core/expo/expo.service';
2022

2123
export type UniqueProposal = UUID;
2224

@@ -38,6 +40,7 @@ export class ProposalsService {
3840
private db: DatabaseService,
3941
private networks: NetworksService,
4042
private pubsub: PubsubService,
43+
private expo: ExpoService,
4144
) {}
4245

4346
async selectUnique(id: UUID, shape: ShapeFunc<typeof e.Proposal>) {
@@ -77,7 +80,7 @@ export class ProposalsService {
7780
...shape?.(p),
7881
...(pendingFilter ? { pendingFilter } : {}), // Must be included in the select (not just the filter) to avoid bug
7982
filter: and(e.op(p.account, '=', e.cast(e.Account, account)), pendingFilter),
80-
order_by: p.createdAt,
83+
order_by: { expression: p.createdAt, direction: e.DESC },
8184
};
8285
}),
8386
{ account },
@@ -110,6 +113,7 @@ export class ProposalsService {
110113
});
111114

112115
this.event(approval.proposal, ProposalEvent.approval);
116+
this.notifyApprovers(proposal);
113117
}
114118

115119
async reject(proposal: UUID) {
@@ -166,4 +170,27 @@ export class ProposalsService {
166170
event,
167171
});
168172
}
173+
174+
async notifyApprovers(proposal: UUID) {
175+
// Get proposal policy approvers if proposer has approved or can't else {}
176+
const p = await this.db.exec(approversToNotify, { proposal });
177+
if (!p) return;
178+
179+
await this.expo.sendNotification(
180+
p.approvers
181+
.filter((a) => a.pushToken)
182+
.map((a) => ({
183+
to: a.pushToken!,
184+
title: `Approval required for ${p.isTransaction ? `transaction` : `message`}`,
185+
channelId: 'activity',
186+
priority: 'normal',
187+
data: {
188+
href: {
189+
pathname: `/(nav)/transaction/[id]`,
190+
params: { id: proposal },
191+
},
192+
},
193+
})),
194+
);
195+
}
169196
}

api/src/feat/transactions/transactions.service.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,13 @@ export class TransactionsService {
323323
});
324324
const id = asUUID(r.id);
325325

326-
this.proposals.event({ id, account }, ProposalEvent.create);
327326
if (signature) {
328327
await this.approve({ id, signature }, true);
329328
} else {
330329
afterRequest(() => this.tryExecute(id));
331330
}
331+
this.proposals.event({ id, account }, ProposalEvent.create);
332+
this.proposals.notifyApprovers(id);
332333

333334
return id;
334335
}

api/src/feat/transfers/transfers.events.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,7 @@ export class TransfersEvents {
115115
false,
116116
),
117117
})
118-
.unlessConflict((t) => ({
119-
on: e.tuple([t.account, t.block, t.logIndex]),
120-
})),
118+
.unlessConflict(),
121119
(t) => ({
122120
id: true,
123121
internal: true,

app/src/app/_layout.tsx

+18-1
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,29 @@ import { GoogleAuthProvider } from '#/cloud/google/GoogleAuthProvider';
2525
import { Try } from 'expo-router/build/views/Try';
2626
import { PortalProvider } from '@gorhom/portal';
2727
import { GlobalSubscriptions } from '#/GlobalSubscriptions/GlobalSubscriptions';
28+
import { createStyles, useStyles } from '@theme/styles';
2829

2930
export const unstable_settings = {
3031
initialRouteName: `index`,
3132
};
3233

3334
function Layout() {
35+
const { styles } = useStyles(stylesheet);
36+
3437
return (
35-
<Stack screenOptions={{ headerShown: false }}>
38+
<Stack
39+
screenOptions={{
40+
headerShown: false,
41+
contentStyle: styles.stackContent,
42+
}}
43+
>
3644
<Stack.Screen
3745
name={`(modal)`}
3846
options={{
3947
presentation: 'transparentModal',
4048
animation: 'fade',
4149
animationDuration: 100,
50+
contentStyle: styles.transparentContent,
4251
}}
4352
/>
4453
<Stack.Screen
@@ -47,12 +56,20 @@ function Layout() {
4756
presentation: 'transparentModal',
4857
animation: 'fade',
4958
animationDuration: 100,
59+
contentStyle: styles.transparentContent,
5060
}}
5161
/>
5262
</Stack>
5363
);
5464
}
5565

66+
const stylesheet = createStyles(({ colors }) => ({
67+
stackContent: {
68+
backgroundColor: colors.surface,
69+
},
70+
transparentContent: {},
71+
}));
72+
5673
function RootLayout() {
5774
return (
5875
<SafeAreaProvider>

app/src/app/onboard/_layout.tsx

-5
This file was deleted.

app/src/app/scan.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export default function ScanScreen() {
6060
showError('Failed to connect. Please refresh the DApp and try again');
6161
}
6262
} else if (parseAppLink(data)) {
63-
router.replace(parseAppLink(data)!);
63+
router.push(parseAppLink(data)!);
6464
return true;
6565
}
6666

app/src/components/NotificationSettings.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ export function NotificationSettings({ next }: NotificationSettingsProps) {
104104
))}
105105

106106
<Actions horizontal>
107-
{!perm?.granted && next && <Button onPress={next}>Skip</Button>}
108-
109107
{(!perm?.granted || next) && (
110108
<Button
111109
mode="contained"
@@ -117,6 +115,8 @@ export function NotificationSettings({ next }: NotificationSettingsProps) {
117115
{perm?.granted ? 'Continue' : 'Enable'}
118116
</Button>
119117
)}
118+
119+
{!perm?.granted && next && <Button onPress={next}>Skip</Button>}
120120
</Actions>
121121
</>
122122
);

0 commit comments

Comments
 (0)