Skip to content

Commit 3419682

Browse files
committed
channels: display remote fee rates on Loop page
1 parent 755ed1b commit 3419682

File tree

8 files changed

+171
-11
lines changed

8 files changed

+171
-11
lines changed

app/src/__tests__/store/channelStore.spec.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BalanceMode } from 'util/constants';
88
import {
99
lndChannel,
1010
lndChannelEvent,
11+
lndGetChanInfo,
1112
lndGetNodeInfo,
1213
lndListChannels,
1314
} from 'util/tests/sampleData';
@@ -132,7 +133,7 @@ describe('ChannelStore', () => {
132133
const channel = store.channels.get(lndChannel.chanId) as Channel;
133134
expect(channel.alias).toBeUndefined();
134135
// the alias is fetched from the API and should be updated after a few ticks
135-
waitFor(() => {
136+
await waitFor(() => {
136137
expect(channel.alias).toBe(lndGetNodeInfo.node.alias);
137138
});
138139
});
@@ -157,6 +158,40 @@ describe('ChannelStore', () => {
157158
expect(grpcMock.unary).not.toBeCalled();
158159
});
159160

161+
it('should fetch fee rates for channels', async () => {
162+
await store.fetchChannels();
163+
const channel = store.channels.get(lndChannel.chanId) as Channel;
164+
expect(channel.remoteFeeRate).toBe(0);
165+
// the alias is fetched from the API and should be updated after a few ticks
166+
await waitFor(() => {
167+
const rate = +Big(lndGetChanInfo.node1Policy.feeRateMilliMsat)
168+
.div(1000000)
169+
.mul(100);
170+
expect(channel.remoteFeeRate).toBe(rate);
171+
});
172+
});
173+
174+
it('should use cached fee rates for channels', async () => {
175+
const rate = +Big(lndGetChanInfo.node1Policy.feeRateMilliMsat).div(1000000).mul(100);
176+
const cache = {
177+
lastUpdated: Date.now(),
178+
feeRates: {
179+
[lndGetChanInfo.channelId]: rate,
180+
},
181+
};
182+
const getMock = appStorageMock.mock.instances[0].get as jest.Mock;
183+
getMock.mockImplementationOnce(() => cache);
184+
185+
const channel = new Channel(rootStore, lndChannel);
186+
store.channels = observable.map({
187+
[channel.chanId]: channel,
188+
});
189+
190+
await store.fetchFeeRates();
191+
expect(channel.remoteFeeRate).toBe(rate);
192+
expect(grpcMock.unary).not.toBeCalled();
193+
});
194+
160195
describe('onChannelEvent', () => {
161196
const {
162197
OPEN_CHANNEL,

app/src/api/lnd.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ class LndApi extends BaseApi<LndEvents> {
6666
return res.toObject();
6767
}
6868

69+
/**
70+
* call the LND `GetChanInfo` RPC and return the response
71+
*/
72+
async getChannelInfo(id: string): Promise<LND.ChannelEdge.AsObject> {
73+
const req = new LND.ChanInfoRequest();
74+
req.setChanId(id);
75+
const res = await this._grpc.request(Lightning.GetChanInfo, req, this._meta);
76+
return res.toObject();
77+
}
78+
6979
/**
7080
* Connect to the LND streaming endpoints
7181
*/

app/src/components/loop/ChannelBalance.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const Styled = {
99
Wrapper: styled.div<{ pct: number; status: BalanceStatus; active: boolean }>`
1010
display: flex;
1111
width: 100%;
12+
padding: 0 5%;
1213
1314
> div {
1415
min-width: 6px;

app/src/components/loop/ChannelRow.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const Styled = {
3939
Column: styled(Column)<{ last?: boolean }>`
4040
white-space: nowrap;
4141
line-height: ${ROW_HEIGHT}px;
42+
overflow: hidden;
43+
text-overflow: ellipsis;
44+
padding-left: 5px;
45+
padding-right: ${props => (props.last ? '15' : '5')}px;
4246
`,
4347
StatusIcon: styled.span`
4448
float: left;
@@ -58,6 +62,7 @@ const Styled = {
5862

5963
export const ChannelRowHeader: React.FC = () => {
6064
const { l } = usePrefixedTranslation('cmps.loop.ChannelRowHeader');
65+
const { Column } = Styled;
6166
return (
6267
<Row>
6368
<Column right>
@@ -67,13 +72,16 @@ export const ChannelRowHeader: React.FC = () => {
6772
<Column>
6873
<HeaderFour>{l('canSend')}</HeaderFour>
6974
</Column>
70-
<Column>
75+
<Column cols={1}>
76+
<HeaderFour>{l('feeRate')}</HeaderFour>
77+
</Column>
78+
<Column cols={1}>
7179
<HeaderFour>{l('upTime')}</HeaderFour>
7280
</Column>
73-
<Column>
81+
<Column cols={2}>
7482
<HeaderFour>{l('peer')}</HeaderFour>
7583
</Column>
76-
<Column right>
84+
<Column right last>
7785
<HeaderFour>{l('capacity')}</HeaderFour>
7886
</Column>
7987
</Row>
@@ -134,8 +142,9 @@ const ChannelRow: React.FC<Props> = ({ channel, style }) => {
134142
<Column>
135143
<Unit sats={channel.localBalance} suffix={false} />
136144
</Column>
137-
<Column>{channel.uptimePercent}</Column>
138-
<Column>
145+
<Column cols={1}>{channel.remoteFeeRate}</Column>
146+
<Column cols={1}>{channel.uptimePercent}</Column>
147+
<Column cols={2}>
139148
<Tip overlay={channel.remotePubkey} placement="left">
140149
<span>{channel.aliasLabel}</span>
141150
</Tip>

app/src/i18n/locales/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"cmps.history.HistoryRowHeader.updated": "Updated",
2222
"cmps.loop.ChannelRowHeader.canReceive": "Can Receive",
2323
"cmps.loop.ChannelRowHeader.canSend": "Can Send",
24+
"cmps.loop.ChannelRowHeader.feeRate": "In Fee %",
2425
"cmps.loop.ChannelRowHeader.upTime": "Up Time %",
2526
"cmps.loop.ChannelRowHeader.peer": "Peer/Alias",
2627
"cmps.loop.ChannelRowHeader.capacity": "Capacity",

app/src/store/models/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default class Channel {
1818
@observable capacity = Big(0);
1919
@observable localBalance = Big(0);
2020
@observable remoteBalance = Big(0);
21+
@observable remoteFeeRate = 0;
2122
@observable active = false;
2223
@observable uptime = Big(0);
2324
@observable lifetime = Big(0);

app/src/store/stores/channelStore.ts

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,18 @@ const {
2222

2323
interface AliasCache {
2424
lastUpdated: number;
25+
/** mapping from remove pubkey to alias */
2526
aliases: Record<string, string>;
2627
}
2728

29+
interface FeeCache {
30+
lastUpdated: number;
31+
/** mapping form channel id to fee rate */
32+
feeRates: Record<string, number>;
33+
}
34+
2835
/** cache alias data for 24 hours */
29-
const ALIAS_CACHE_TIMEOUT = 24 * 60 * 60 * 1000;
36+
const CACHE_TIMEOUT = 24 * 60 * 60 * 1000;
3037

3138
export default class ChannelStore {
3239
private _store: Store;
@@ -103,6 +110,8 @@ export default class ChannelStore {
103110
this._store.log.info('updated channelStore.channels', toJS(this.channels));
104111
// fetch the aliases for each of the channels
105112
this.fetchAliases();
113+
// fetch the remote fee rates for each of the channels
114+
this.fetchFeeRates();
106115
});
107116
} catch (error) {
108117
this._store.uiStore.handleError(error, 'Unable to fetch Channels');
@@ -129,7 +138,7 @@ export default class ChannelStore {
129138

130139
// look up cached data in storage
131140
let cachedAliases = this._store.storage.get<AliasCache>('aliases');
132-
if (cachedAliases && cachedAliases.lastUpdated > Date.now() - ALIAS_CACHE_TIMEOUT) {
141+
if (cachedAliases && cachedAliases.lastUpdated > Date.now() - CACHE_TIMEOUT) {
133142
// there is cached data and it has not expired
134143
aliases = cachedAliases.aliases;
135144
// exclude pubkeys which we have aliases for already
@@ -162,11 +171,75 @@ export default class ChannelStore {
162171
runInAction('fetchAliasesContinuation', () => {
163172
// set the alias on each channel in the store
164173
values(this.channels).forEach(c => {
165-
if (aliases[c.remotePubkey]) {
166-
c.alias = aliases[c.remotePubkey];
174+
const alias = aliases[c.remotePubkey];
175+
if (alias) {
176+
c.alias = alias;
177+
this._store.log.info(`updated channel ${c.chanId} with alias ${alias}`);
178+
}
179+
});
180+
});
181+
}
182+
183+
/**
184+
* queries the LND api to fetch the fees for all of the peers we have
185+
* channels opened with
186+
*/
187+
@action.bound
188+
async fetchFeeRates() {
189+
this._store.log.info('fetching fees for channels');
190+
// create an array of all channel ids
191+
let chanIds = values(this.channels)
192+
.map(c => c.chanId)
193+
.filter((r, i, a) => a.indexOf(r) === i); // remove duplicates
194+
195+
// create a map of chan id to fee rate
196+
let feeRates: Record<string, number> = {};
197+
198+
// look up cached data in storage
199+
let cachedFees = this._store.storage.get<FeeCache>('fee-rates');
200+
if (cachedFees && cachedFees.lastUpdated > Date.now() - CACHE_TIMEOUT) {
201+
// there is cached data and it has not expired
202+
feeRates = cachedFees.feeRates;
203+
// exclude chanIds which we have feeRates for already
204+
chanIds = chanIds.filter(id => !feeRates[id]);
205+
this._store.log.info(`found feeRates in cache. ${chanIds.length} missing`, chanIds);
206+
}
207+
208+
// if there are any chanIds that we do not have a cached fee rate for
209+
if (chanIds.length) {
210+
// call getNodeInfo for each chan id and wait for all the requests to complete
211+
const chanInfos = await Promise.all(
212+
chanIds.map(id => this._store.api.lnd.getChannelInfo(id)),
213+
);
214+
215+
// add fetched feeRates to the mapping
216+
feeRates = chanInfos.reduce((acc, info) => {
217+
const { channelId, node1Pub, node1Policy, node2Policy } = info;
218+
const localPubkey = this._store.nodeStore.pubkey;
219+
const policy = node1Pub === localPubkey ? node2Policy : node1Policy;
220+
if (policy) {
221+
acc[channelId] = +Big(policy.feeRateMilliMsat).div(1000000).mul(100);
222+
}
223+
return acc;
224+
}, feeRates);
225+
226+
// save updated feeRates to the cache in storage
227+
cachedFees = {
228+
lastUpdated: Date.now(),
229+
feeRates,
230+
};
231+
this._store.storage.set('fee-rates', cachedFees);
232+
this._store.log.info(`updated cache with ${chanIds.length} new feeRates`);
233+
}
234+
235+
runInAction('fetchFeesContinuation', () => {
236+
// set the fee on each channel in the store
237+
values(this.channels).forEach(c => {
238+
if (feeRates[c.chanId]) {
239+
c.remoteFeeRate = feeRates[c.chanId];
167240
}
168241
});
169-
this._store.log.info('updated channels with aliases', toJS(this.channels));
242+
this._store.log.info('updated channels with feeRates', toJS(this.channels));
170243
});
171244
}
172245

@@ -206,6 +279,8 @@ export default class ChannelStore {
206279
this._store.nodeStore.fetchBalancesThrottled();
207280
// fetch the alias for the added channel
208281
this.fetchAliases();
282+
// fetch the remote fee rates for the added channel
283+
this.fetchFeeRates();
209284
}
210285
}
211286

app/src/util/tests/sampleData.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,33 @@ export const lndTransaction: LND.Transaction.AsObject = {
175175
txHash: '1f765f45f2a6d33837a203e3fc911915c891e9b86f9c9d91a1931b92efdedf5b',
176176
};
177177

178+
export const lndGetChanInfo: Required<LND.ChannelEdge.AsObject> = {
179+
channelId: lndChannel.chanId,
180+
chanPoint: lndChannel.channelPoint,
181+
lastUpdate: 1591622793,
182+
node1Pub: lndGetInfo.identityPubkey,
183+
node2Pub: '021626ad63f6876f2baa6000739312690b027ec289b9d1bf9184f3194e8c923dad',
184+
capacity: 1800000,
185+
node1Policy: {
186+
timeLockDelta: 3000,
187+
minHtlc: 1000,
188+
feeBaseMsat: 3000,
189+
feeRateMilliMsat: 300,
190+
disabled: false,
191+
maxHtlcMsat: 1782000000,
192+
lastUpdate: 1591622793,
193+
},
194+
node2Policy: {
195+
timeLockDelta: 40,
196+
minHtlc: 1000,
197+
feeBaseMsat: 1000,
198+
feeRateMilliMsat: 1,
199+
disabled: false,
200+
maxHtlcMsat: 1782000000,
201+
lastUpdate: 1591622772,
202+
},
203+
};
204+
178205
//
179206
// Loop API Responses
180207
//
@@ -214,6 +241,7 @@ export const loopQuote: LOOP.QuoteResponse.AsObject = {
214241
export const sampleApiResponses: Record<string, any> = {
215242
'lnrpc.Lightning.GetInfo': lndGetInfo,
216243
'lnrpc.Lightning.GetNodeInfo': lndGetNodeInfo,
244+
'lnrpc.Lightning.GetChanInfo': lndGetChanInfo,
217245
'lnrpc.Lightning.ChannelBalance': lndChannelBalance,
218246
'lnrpc.Lightning.WalletBalance': lndWalletBalance,
219247
'lnrpc.Lightning.ListChannels': lndListChannels,

0 commit comments

Comments
 (0)